From 93248a232eefddd321f3f0c267aa86ee7511fa23 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 13 Nov 2025 17:34:54 -0300 Subject: [PATCH 01/84] =?UTF-8?q?=E2=9C=A8=20server:=20allow=20account=20o?= =?UTF-8?q?n=20firewall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/every-cameras-trade.md | 5 +++++ server/hooks/persona.ts | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 .changeset/every-cameras-trade.md diff --git a/.changeset/every-cameras-trade.md b/.changeset/every-cameras-trade.md new file mode 100644 index 000000000..ca7b42be2 --- /dev/null +++ b/.changeset/every-cameras-trade.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ allow account on firewall diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index a3f0c130a..7f33f77c5 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -20,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 keeper from "../utils/keeper"; import { createUser } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { addDocument, headerValidator, MANTECA_TEMPLATE_WITH_ID_CLASS, PANDA_TEMPLATE } from "../utils/persona"; @@ -309,6 +311,15 @@ export default new Hono().post( level: "error", }); } + + if (firewallAddress) { + keeper + .exaSend( + { name: "exa.firewall", op: "exa.firewall", attributes: { account: credential.account, personaShareToken } }, + { address: firewallAddress, functionName: "allow", args: [credential.account, true], abi: firewallAbi }, + ) + .catch((error: unknown) => captureException(error, { level: "error" })); + } addDocument(referenceId, { id_class: { value: fields.identificationClass.value }, id_number: { value: fields.identificationNumber.value }, From d3e4907c530630ab6b56f2a788ac5c398fe97c32 Mon Sep 17 00:00:00 2001 From: mainqueg Date: Wed, 23 Jul 2025 10:11:46 -0300 Subject: [PATCH 02/84] =?UTF-8?q?=E2=9C=A8=20server:=20implement=20kyc=20d?= =?UTF-8?q?ata=20submission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ^ Conflicts: ^ server/api/card.ts ^ server/test/api/card.test.ts --- .changeset/four-numbers-worry.md | 5 + server/api/card.ts | 23 ++- server/api/kyc.ts | 197 ++++++++++++++++++++++++- server/script/openapi.ts | 2 + server/test/api/card.test.ts | 115 ++------------- server/test/api/kyc.test.ts | 237 ++++++++++++++++++++++++++++++- server/test/e2e.ts | 1 + server/utils/panda.ts | 117 ++++++++++++++- 8 files changed, 590 insertions(+), 107 deletions(-) create mode 100644 .changeset/four-numbers-worry.md diff --git a/.changeset/four-numbers-worry.md b/.changeset/four-numbers-worry.md new file mode 100644 index 000000000..6d50428b8 --- /dev/null +++ b/.changeset/four-numbers-worry.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ implement kyc data submission diff --git a/server/api/card.ts b/server/api/card.ts index 2a7479eea..81eb16525 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -31,7 +31,17 @@ import { Address } from "@exactly/common/validation"; import database, { cards, credentials } from "../database"; import auth from "../middleware/auth"; import { sendPushNotification } from "../utils/onesignal"; -import { autoCredit, createCard, getCard, getPIN, getSecrets, getUser, setPIN, updateCard } from "../utils/panda"; +import { + autoCredit, + createCard, + getApplicationStatus, + getCard, + getPIN, + getSecrets, + getUser, + setPIN, + updateCard, +} from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { getAccount } from "../utils/persona"; import { customer } from "../utils/sardine"; @@ -292,7 +302,12 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str 403: { description: "Forbidden", content: { - "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) }, + "application/json": { + schema: resolver( + union([object({ code: literal("no panda") }), object({ code: literal("kyc not approved") })]), + { errorMode: "ignore" }, + ), + }, }, }, }, @@ -317,6 +332,10 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str setUser({ id: account }); if (!credential.pandaId) return c.json({ code: "no panda" }, 403); + const kyc = await getApplicationStatus(credential.pandaId); + if (kyc.applicationStatus !== "approved") { + return c.json({ code: "kyc not approved" }, 403); + } let isUpgradeFromPlatinum = credential.cards.some( ({ status, productId }) => status === "DELETED" && productId === PLATINUM_PRODUCT_ID, diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 223f7406a..82973eb1c 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -2,8 +2,9 @@ import { captureException, setContext, setUser, startSpan } from "@sentry/node"; import createDebug from "debug"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { validator as vValidator } from "hono-openapi/valibot"; -import { literal, object, optional, parse, picklist, string } from "valibot"; +import { describeRoute } from "hono-openapi"; +import { resolver, validator as vValidator } from "hono-openapi/valibot"; +import { array, literal, metadata, object, optional, parse, picklist, pipe, string, union } from "valibot"; import { getAddress } from "viem"; import accountInit from "@exactly/common/accountInit"; @@ -17,6 +18,13 @@ import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; import auth from "../middleware/auth"; import decodePublicKey from "../utils/decodePublicKey"; +import { + SubmitApplicationRequest as Application, + UpdateApplicationRequest as ApplicationUpdate, + getApplicationStatus, + submitApplication, + updateApplication, +} from "../utils/panda"; import { createInquiry, CRYPTOMATE_TEMPLATE, @@ -33,6 +41,19 @@ import validatorHook from "../utils/validatorHook"; const debug = createDebug("exa:kyc"); Object.assign(debug, { inspectOpts: { depth: undefined } }); +const KYCStatusResponse = object({ + code: pipe(string(), metadata({ examples: ["ok"] })), + legacy: pipe(string(), metadata({ examples: ["ok"] })), + status: pipe(string(), metadata({ examples: ["approved", "rejected"] })), + reason: pipe(string(), metadata({ examples: ["", "BAD_SELFIE"] })), +}); + +const BadRequestCodes = { + ALREADY_STARTED: "already started", + NOT_STARTED: "not started", + BAD_REQUEST: "bad request", +} as const; + export default new Hono() .get( "/", @@ -184,6 +205,171 @@ export default new Hono() throw new Error("Unknown inquiry status"); } }, + ) + .post( + "/application", + auth(), + describeRoute({ + summary: "Submit KYC application", + description: "Submit information for KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application submitted successfully", + content: { + "application/json": { + schema: resolver(buildBaseResponse("ok"), { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.ALREADY_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + validateResponse: true, + }), + vValidator("json", Application, validatorHook({ debug })), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const payload = c.req.valid("json"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + setContext("exa", { credential }); + + if (credential.pandaId) { + return c.json({ code: BadRequestCodes.ALREADY_STARTED, legacy: BadRequestCodes.ALREADY_STARTED }, 400); + } + + const application = await submitApplication(payload); + await database.update(credentials).set({ pandaId: application.id }).where(eq(credentials.id, credentialId)); + return c.json({ code: "ok", legacy: "ok" }, 200); + }, + ) + .patch( + "/application", + auth(), + describeRoute({ + summary: "Update KYC application", + description: "Update the KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application updated successfully", + content: { + "application/json": { + schema: resolver(buildBaseResponse("ok"), { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.NOT_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + validateResponse: true, + }), + vValidator("json", ApplicationUpdate, validatorHook({ debug })), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const payload = c.req.valid("json"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + setContext("exa", { credential }); + if (!credential.pandaId) { + return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); + } + await updateApplication(credential.pandaId, payload); + return c.json({ code: "ok", legacy: "ok" }, 200); + }, + ) + .get( + "/application", + auth(), + describeRoute({ + summary: "Get KYC application status", + description: "Get the status of the KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application status", + content: { + "application/json": { + schema: resolver(KYCStatusResponse, { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.NOT_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + setContext("exa", { credential }); + if (!credential.pandaId) { + return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); + } + const status = await getApplicationStatus(credential.pandaId); + return c.json( + { code: "ok", legacy: "ok", status: status.applicationStatus, reason: status.applicationReason ?? "unknown" }, + 200, + ); + }, ); async function isLegacy( @@ -216,3 +402,10 @@ async function generateInquiryTokens(inquiryId: string): Promise<{ inquiryId: st const { meta: sessionTokenMeta } = await resumeInquiry(inquiryId); return { inquiryId, sessionToken: sessionTokenMeta["session-token"] }; } + +function buildBaseResponse(example = "string") { + return object({ + code: pipe(string(), metadata({ examples: [example] })), + legacy: pipe(string(), metadata({ examples: [example] })), + }); +} diff --git a/server/script/openapi.ts b/server/script/openapi.ts index 124c51240..553dff196 100644 --- a/server/script/openapi.ts +++ b/server/script/openapi.ts @@ -21,6 +21,8 @@ process.env.PANDA_API_URL = "https://panda.test"; process.env.PAX_API_KEY = "pax"; process.env.PAX_API_URL = "https://pax.test"; process.env.PAX_ASSOCIATE_ID_KEY = "pax"; +process.env.KYC_API_KEY = "panda"; +process.env.KYC_API_URL = "https://panda.test"; process.env.PERSONA_API_KEY = "persona"; process.env.PERSONA_URL = "https://persona.test"; process.env.PERSONA_WEBHOOK_SECRET = "persona"; diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 04ea8809b..e684b6dbc 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -91,6 +91,7 @@ describe("authenticated", () => { afterEach(() => vi.resetAllMocks()); it("returns 404 card not found", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, { headers: { "test-credential-id": "404" } }, @@ -111,14 +112,13 @@ describe("authenticated", () => { }); it("returns panda card as default platinum product", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); - vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); - const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, { headers: { "test-credential-id": "default" } }, @@ -186,11 +186,6 @@ describe("authenticated", () => { factory: inject("ExaAccountFactory"), }, ]); - await database.insert(cards).values([{ id: `card-${foo}`, credentialId: foo, lastFour: "4567" }]); - - vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); - vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); - vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -198,6 +193,7 @@ describe("authenticated", () => { ); expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); }); it("returns 403 when panda user is not found", async () => { @@ -340,6 +336,7 @@ describe("authenticated", () => { }); it("returns 403 when panda user exists but is not approved", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "denied" }); const credentialId = "not-approved"; await database.insert(credentials).values({ id: credentialId, @@ -349,104 +346,13 @@ describe("authenticated", () => { pandaId: credentialId, }); - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => - Promise.resolve('{"message":"User exists, but is not approved","error":"ForbiddenError","statusCode":403}'), - } as Response); - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + await expect(response.json()).resolves.toStrictEqual({ code: "kyc not approved" }); expect(captureException).not.toHaveBeenCalled(); }); - it("returns 403 when createCard fails with plain-text not approved", async () => { - const credentialId = "not-approved-plain"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4043", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => Promise.resolve("user exists but is not approved"), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).not.toHaveBeenCalled(); - }); - - it("returns 403 when createCard fails with panda user not found", async () => { - const credentialId = "panda-user-not-found"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4042", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve('{"message":"User not found","error":"NotFoundError","statusCode":404}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - - it("returns 403 when createCard fails with panda user not found and empty body", async () => { - const credentialId = "panda-user-not-found-empty"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4044", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve(""), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - - it("captures forbidden no-user on createCard when credential has card history", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => - Promise.resolve('{"message":"User exists, but is not approved","error":"ForbiddenError","statusCode":403}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": "404" } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - it("throws when createCard fails with empty-body 403", async () => { const credentialId = "not-approved-empty"; await database.insert(credentials).values({ @@ -491,6 +397,7 @@ describe("authenticated", () => { it("creates a panda debit card with signature product id", async () => { vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCard" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$post({ header: { "test-credential-id": "sig" } }); const json = await response.json(); @@ -512,6 +419,7 @@ describe("authenticated", () => { it("creates a panda credit card with signature product id", async () => { vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCreditCard", last4: "1224" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$post({ header: { "test-credential-id": "eth" } }); const json = await response.json(); @@ -602,7 +510,7 @@ describe("authenticated", () => { }, }, }; - + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "pax-card", last4: "5555" }); @@ -637,6 +545,7 @@ describe("authenticated", () => { pandaId: "new-user-panda", }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, @@ -720,7 +629,7 @@ describe("authenticated", () => { }, }, }; - + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockRejectedValueOnce(new Error("pax api error")); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "error-card", last4: "6666" }); @@ -750,6 +659,7 @@ describe("authenticated", () => { productId: PLATINUM_PRODUCT_ID, }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "no-account-card", last4: "7777" }); @@ -764,6 +674,7 @@ describe("authenticated", () => { const cardResponse = { ...cardTemplate, id: "cardForCancel", last4: "1224", status: "active" as const }; vi.spyOn(panda, "createCard").mockResolvedValueOnce(cardResponse); vi.spyOn(panda, "updateCard").mockResolvedValueOnce({ ...cardResponse, status: "canceled" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$post({ header: { "test-credential-id": "eth" } }); @@ -788,6 +699,7 @@ describe("authenticated", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "getCard").mockRejectedValueOnce(new ServiceError("Panda", 404, "card not found")); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:cm" }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); @@ -805,6 +717,7 @@ describe("authenticated", () => { it("creates a panda card having a cm card with invalid uuid", async () => { await database.insert(cards).values([{ id: "not-uuid", credentialId: "default", lastFour: "1234" }]); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:not-uuid" }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 731cbe6b8..8a8f241c6 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -5,19 +5,41 @@ import "../mocks/sentry"; import { captureException } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { afterEach, beforeEach, describe, expect, inject, it, vi } from "vitest"; +import { padHex, zeroAddress, zeroHash } from "viem"; +import { privateKeyToAddress } from "viem/accounts"; +import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; + +import deriveAddress from "@exactly/common/deriveAddress"; import app from "../../api/kyc"; import database, { credentials } from "../../database"; +import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; import { scopeValidationErrors } from "../../utils/persona"; import publicClient from "../../utils/publicClient"; +import type * as v from "valibot"; + const appClient = testClient(app); vi.mock("@sentry/node", { spy: true }); describe("authenticated", () => { + const bob = privateKeyToAddress(padHex("0xb0b2")); + const account = deriveAddress(inject("ExaAccountFactory"), { x: padHex(bob), y: zeroHash }); + + beforeAll(async () => { + await database.insert(credentials).values([ + { + id: account, + publicKey: new Uint8Array(), + account, + factory: zeroAddress, + pandaId: "pandaId", + }, + ]); + }); + beforeEach(async () => { await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); }); @@ -987,6 +1009,193 @@ describe("authenticated", () => { }); }); }); + + describe("application", () => { + describe("status", () => { + it("returns status", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ + id: "pandaId", + applicationStatus: "approved", + applicationReason: "", + }); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ + code: "ok", + legacy: "ok", + status: "approved", + reason: "", + }); + expect(getApplicationStatus).toHaveBeenCalledWith("pandaId"); + expect(response.status).toBe(200); + }); + + it("returns not started when no panda id", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + }); + + describe("submit", () => { + it("returns ok when payload is valid and kyc is not started", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { json: applicationPayload }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user`), + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual(applicationPayload); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + }); + + it("returns 400 when kyc is already started", async () => { + const submitApplication = vi.spyOn(panda, "submitApplication"); + + const response = await appClient.application.$post( + { json: applicationPayload }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "already started", + legacy: "already started", + }); + expect(submitApplication).not.toHaveBeenCalled(); + }); + + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$post( + { json: {} as unknown as v.InferOutput }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); + + it("returns 400 if terms of service are not accepted", async () => { + const response = await appClient.application.$post( + { json: { ...applicationPayload, isTermsOfServiceAccepted: false } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad request", + legacy: "bad request", + message: ["isTermsOfServiceAccepted Invalid type: Expected true but received false"], + }); + }); + }); + + describe("update", () => { + it("returns ok when kyc is started", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode("{}").buffer), + } as Response); + + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user/pandaId`), + expect.objectContaining({ + method: "PATCH", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ firstName: "john-updated" }); + }); + + it("returns 400 when kyc is not started", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$patch( + { + json: { + address: { + line1: "123 main street", + }, + } as unknown as v.InferOutput, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); + }); + }); }); const basicAccount = { @@ -1266,3 +1475,29 @@ const inquiry = { }, }, } as const; + +const applicationPayload = { + firstName: "john", + lastName: "doe", + birthDate: "1990-01-15", + nationalId: "123456789", + countryOfIssue: "AA", + email: "john.doe@example.com", + phoneCountryCode: "1", + phoneNumber: "5551234567", + ipAddress: "192.168.1.1", + occupation: "occupation", + annualSalary: "1234", + accountPurpose: "purpose", + expectedMonthlyVolume: "1234", + isTermsOfServiceAccepted: true, + address: { + line1: "123 main street", + line2: "apt 1", + city: "city", + region: "region", + postalCode: "1234", + countryCode: "AA", + country: "country", + }, +} as const; diff --git a/server/test/e2e.ts b/server/test/e2e.ts index c5c73ce05..4e2f326d9 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -109,6 +109,7 @@ vi.mock("../utils/panda", async (importOriginal: () => Promise) => }); }), getUser: vi.fn().mockImplementation((userId: string) => Promise.resolve(users.get(userId))), + getApplicationStatus: vi.fn().mockResolvedValue({ id: "pandaId", applicationStatus: "approved" }), isPanda: vi.fn().mockResolvedValue(true), setPIN: vi.fn().mockResolvedValue({}), signIssuerOp: vi.fn().mockResolvedValue("0x" + "ab".repeat(65)), diff --git a/server/utils/panda.ts b/server/utils/panda.ts index cbca7e0bc..34fe92e4e 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -3,20 +3,31 @@ import { Mutex, withTimeout, type MutexInterface } from "async-mutex"; import { eq } from "drizzle-orm"; import { boolean, + check, + email, + ipv4, + ipv6, length, literal, maxLength, + metadata, minLength, nullable, number, object, + omit, + optional, parse, + partial, picklist, pipe, + regex, string, transform, + union, type BaseIssue, type BaseSchema, + type InferInput, } from "valibot"; import { BaseError, ContractFunctionZeroDataError } from "viem"; import { privateKeyToAccount } from "viem/accounts"; @@ -47,7 +58,6 @@ const baseURL = process.env.PANDA_API_URL; if (!process.env.PANDA_API_KEY) throw new Error("missing panda api key"); const key = process.env.PANDA_API_KEY; -export default key; export async function createCard(userId: string, productId: typeof PLATINUM_PRODUCT_ID | typeof SIGNATURE_PRODUCT_ID) { return await request( @@ -366,3 +376,108 @@ export function createMutex(address: Address) { export function getMutex(address: Address) { return mutexes.get(address); } + +export async function submitApplication(payload: InferInput) { + return request(ApplicationResponse, "/issuing/applications/user", {}, payload, "POST"); +} + +export async function getApplicationStatus(applicationId: string) { + return request(ApplicationStatusResponse, `/issuing/applications/user/${applicationId}`, {}, undefined, "GET"); +} + +export async function updateApplication(applicationId: string, payload: InferInput) { + return request(object({}), `/issuing/applications/user/${applicationId}`, {}, payload, "PATCH"); +} + +const AddressSchema = object({ + line1: pipe(string(), minLength(1), maxLength(100)), + line2: optional(pipe(string(), minLength(1), maxLength(100))), + city: pipe(string(), minLength(1), maxLength(50)), + region: pipe(string(), minLength(1), maxLength(50)), + country: optional(pipe(string(), minLength(1), maxLength(50))), + postalCode: pipe(string(), minLength(1), maxLength(15), regex(/^[a-z0-9]{1,15}$/i)), + countryCode: pipe(string(), length(2), regex(/^[A-Z]{2}$/i)), +}); + +export const SubmitApplicationRequest = object({ + email: pipe( + string(), + email("Invalid email address"), + metadata({ description: "Email address", examples: ["user@domain.com"] }), + ), + lastName: pipe(string(), maxLength(50), metadata({ description: "The person's last name" })), + firstName: pipe(string(), maxLength(50), metadata({ description: "The person's first name" })), + nationalId: pipe(string(), maxLength(50), metadata({ description: "The person's national ID" })), + birthDate: pipe( + string(), + regex(/^\d{4}-\d{2}-\d{2}$/, "must be YYYY-MM-DD format"), + check((value) => { + const date = new Date(value); + return !Number.isNaN(date.getTime()); + }, "must be a valid date"), + metadata({ description: "Birth date (YYYY-MM-DD)", examples: ["1970-01-01"] }), + ), + countryOfIssue: pipe( + string(), + length(2), + regex(/^[A-Z]{2}$/i, "Must be exactly 2 letters"), + metadata({ description: "The person's country of issue of their national id, as a 2-digit country code" }), + ), + phoneCountryCode: pipe( + string(), + minLength(1), + maxLength(3), + regex(/^\d{1,3}$/, "Must be a valid country code"), + metadata({ description: "The user's phone country code" }), + ), + phoneNumber: pipe( + string(), + minLength(1), + maxLength(15), + regex(/^\d{1,15}$/, "Must be a valid phone number"), + metadata({ description: "The user's phone number" }), + ), + address: pipe(AddressSchema, metadata({ description: "The person's address" })), + ipAddress: pipe( + union([pipe(string(), maxLength(50), ipv4()), pipe(string(), maxLength(50), ipv6())]), + metadata({ description: "The user's IP address (IPv4 or IPv6)" }), + ), + occupation: pipe(string(), maxLength(50), metadata({ description: "The user's occupation" })), + annualSalary: pipe(string(), maxLength(50), metadata({ description: "The user's annual salary" })), + accountPurpose: pipe(string(), maxLength(50), metadata({ description: "The user's account purpose" })), + expectedMonthlyVolume: pipe(string(), maxLength(50), metadata({ description: "The user's expected monthly volume" })), + isTermsOfServiceAccepted: pipe( + boolean(), + literal(true), + metadata({ description: "Whether the user has accepted the terms of service" }), + ), +}); + +export const UpdateApplicationRequest = object({ + ...partial(omit(SubmitApplicationRequest, ["email", "phoneCountryCode", "phoneNumber", "address"])).entries, + address: optional(AddressSchema), +}); + +const ApplicationResponse = object({ + id: pipe(string(), maxLength(50)), + applicationStatus: pipe(string(), maxLength(50)), +}); + +export const kycStatus = [ + "needsVerification", + "needsInformation", + "manualReview", + "notStarted", + "approved", + "canceled", + "pending", + "denied", + "locked", +] as const; + +const ApplicationStatusResponse = object({ + id: string(), + applicationStatus: picklist(kycStatus), + applicationReason: optional(string()), +}); +// #endregion schemas From a730df3c275a4aeffc60cdcdff5009321a91e329 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Fri, 1 Aug 2025 13:12:09 -0300 Subject: [PATCH 03/84] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20server:=20add=20s?= =?UTF-8?q?ource=20database=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/database/schema.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/database/schema.ts b/server/database/schema.ts index 711960f71..6274e0c24 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -57,7 +57,15 @@ export const transactions = pgTable("transactions", { payload: jsonb("payload").notNull(), }); -export const credentialsRelations = relations(credentials, ({ many }) => ({ cards: many(cards) })); +export const sources = pgTable("sources", { + id: text("id").primaryKey(), + config: jsonb("config").notNull(), +}); + +export const credentialsRelations = relations(credentials, ({ many, one }) => ({ + cards: many(cards), + source: one(sources, { fields: [credentials.source], references: [sources.id] }), +})); export const cardsRelations = relations(cards, ({ many, one }) => ({ credential: one(credentials, { fields: [cards.credentialId], references: [credentials.id] }), From 2e0658f3aaf3f2ae3755ddab1ff63cee3d45bf97 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Fri, 30 Jan 2026 15:36:20 -0300 Subject: [PATCH 04/84] =?UTF-8?q?=E2=9C=A8=20server:=20forward=20webhooks?= =?UTF-8?q?=20to=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/violet-plums-move.md | 5 + cspell.json | 1 + server/api/kyc.ts | 5 +- server/hooks/panda.ts | 283 +++++++++++++++++++- server/test/api/kyc.test.ts | 15 +- server/test/hooks/panda.test.ts | 439 ++++++++++++++++++++++++++++++-- 6 files changed, 726 insertions(+), 22 deletions(-) create mode 100644 .changeset/violet-plums-move.md diff --git a/.changeset/violet-plums-move.md b/.changeset/violet-plums-move.md new file mode 100644 index 000000000..678c94b8e --- /dev/null +++ b/.changeset/violet-plums-move.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward webhooks to source diff --git a/cspell.json b/cspell.json index 697d00b63..f2a919e27 100644 --- a/cspell.json +++ b/cspell.json @@ -76,6 +76,7 @@ "hdpi", "hexlify", "hideable", + "hmac", "hono", "IBMPlexMono-Medm", "IERC", diff --git a/server/api/kyc.ts b/server/api/kyc.ts index 82973eb1c..ae66adff2 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -259,7 +259,10 @@ export default new Hono() } const application = await submitApplication(payload); - await database.update(credentials).set({ pandaId: application.id }).where(eq(credentials.id, credentialId)); + await database + .update(credentials) + .set({ pandaId: application.id, source: "uphold" }) // TODO get source from signer + .where(eq(credentials.id, credentialId)); return c.json({ code: "ok", legacy: "ok" }, 200); }, ) diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 992c60130..e876f1052 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -13,6 +13,7 @@ import { E_TIMEOUT } from "async-mutex"; import createDebug from "debug"; import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; +import { createHmac } from "node:crypto"; import * as v from "valibot"; import { BaseError, @@ -28,9 +29,11 @@ import { padHex, RawContractError, toBytes, + withRetry, zeroHash, } from "viem"; +import domain from "@exactly/common/domain"; import { auditorAbi, exaPluginAbi, @@ -66,6 +69,9 @@ import type { UnofficialStatusCode } from "hono/utils/http-status"; const debug = createDebug("exa:panda"); Object.assign(debug, { inspectOpts: { depth: undefined } }); +const debugWebhook = createDebug("exa:webhook"); +Object.assign(debugWebhook, { inspectOpts: { depth: undefined } }); + const BaseTransaction = v.object({ id: v.string(), type: v.literal("spend"), @@ -81,7 +87,7 @@ const BaseTransaction = v.object({ merchantCategory: v.nullish(v.string()), merchantCategoryCode: v.string(), merchantName: v.string(), - merchantId: v.optional(v.string()), + merchantId: v.nullish(v.string()), authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), authorizedAmount: v.nullish(v.number()), authorizationMethod: v.optional(v.string()), @@ -114,7 +120,10 @@ const Transaction = v.variant("action", [ authorizationUpdateAmount: v.number(), authorizedAt: v.pipe(v.string(), v.isoTimestamp()), status: v.picklist(["declined", "pending", "reversed"]), - declinedReason: v.optional(v.string()), + declinedReason: v.nullish(v.string()), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), }), }), }), @@ -143,6 +152,9 @@ const Transaction = v.variant("action", [ authorizedAt: v.pipe(v.string(), v.isoTimestamp()), postedAt: v.pipe(v.string(), v.isoTimestamp()), status: v.literal("completed"), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), }), }), }), @@ -203,7 +215,16 @@ const Payload = v.variant("resource", [ action: v.literal("updated"), body: v.object({ applicationReason: v.string(), - applicationStatus: v.string(), + applicationStatus: v.picklist([ + "approved", + "pending", + "needsInformation", + "needsVerification", + "manualReview", + "denied", + "locked", + "canceled", + ]), firstName: v.string(), id: v.string(), isActive: v.boolean(), @@ -227,6 +248,10 @@ export default new Hono().post( setContext("panda", jsonBody); // eslint-disable-line @typescript-eslint/no-unsafe-argument getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `panda.${payload.resource}.${payload.action}`); + startSpan({ name: "webhook", op: "panda.webhook" }, () => publish(payload)).catch((error: unknown) => + captureException(error), + ); + if (payload.resource !== "transaction") { if (payload.resource === "dispute") return c.json({ code: "ok" }); const pandaId = @@ -269,8 +294,8 @@ export default new Hono().post( type: payload.body.spend.amount < 0 ? "return" : "purchase", merchant: { mcc: payload.body.spend.merchantCategoryCode, - id: payload.body.spend.merchantId, name: payload.body.spend.merchantName, + ...(payload.body.spend.merchantId && { id: payload.body.spend.merchantId }), }, terminal: { type: payload.body.spend.authorizationMethod }, address: { countryCode: payload.body.spend.merchantCountry }, @@ -637,7 +662,7 @@ export default new Hono().post( feedback: { type: "authorization", status: "network_declined", - reason: payload.body.spend.declinedReason, + reason: payload.body.spend.declinedReason ?? "unknown", }, }).catch((error: unknown) => captureException(error, { level: "error" })); return c.json({ code: "ok" }); @@ -1150,3 +1175,251 @@ const TransactionPayload = v.object( { bodies: v.array(v.looseObject({ action: v.string() }), "invalid transaction payload") }, "invalid transaction payload", ); + +async function publish(payload: v.InferOutput) { + if (payload.resource === "transaction" && payload.action === "requested") return; + if (payload.resource === "dispute") return; + if (payload.resource === "card" && payload.action === "notification") return; + + async function sendWebhook(webhookPayload: v.InferOutput, url: string, secret: string) { + try { + const result = await withRetry( + async () => { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Signature: createHmac("sha256", secret).update(JSON.stringify(webhookPayload)).digest("hex"), + }, + body: JSON.stringify(webhookPayload), + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) + throw new Error("WebhookFailed", { + cause: { + code: response.status, + response: await response.json(), + payload: webhookPayload, + }, + }); + return response; + }, + { + delay: ({ count }) => Math.trunc(1 << count) * 500, + retryCount: domain === "web.exactly.app" ? 20 : 3, + shouldRetry: ({ error }) => { + if (error instanceof Error) { + return error.message === "WebhookFailed" || error.name === "TimeoutError"; + } + return false; + }, + }, + ); + debugWebhook({ + code: result.status, + response: await result.json(), + payload: webhookPayload, + }); + } catch (error) { + if (error instanceof Error) { + if (error instanceof Error && error.message === "WebhookFailed") { + debugWebhook(error.cause); + } else { + debugWebhook({ + error: error.message, + payload: webhookPayload, + }); + } + } + throw error; + } + } + + const timestamp = new Date().toISOString(); + const user = await database.query.credentials.findFirst({ + columns: { id: true, source: true }, + with: { source: { columns: { config: true } } }, + where: eq( + credentials.pandaId, + (() => { + switch (payload.resource) { + case "card": + return payload.body.userId; + case "user": + return payload.body.id; + case "transaction": + return payload.body.spend.userId; + } + })(), + ), + }); + + if (!user?.source) return; + const config = v.parse(webhookConfig, user.source.config); + await Promise.allSettled( + Object.values(config.webhooks).map(async (webhook) => { + const secret = config.secrets[webhook.secretId]?.key; + if (!secret) throw new Error("secret not found"); + + switch (payload.resource) { + case "user": + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + body: { ...payload.body, credentialId: user.id }, + }), + webhook.card?.[payload.action] ?? webhook.url, + secret, + ); + case "card": + // falls through + case "transaction": + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + }), + webhook.transaction?.[payload.action] ?? webhook.url, + secret, + ); + } + }), + ).then((results) => { + for (const result of results) { + if (result.status === "rejected") captureException(result.reason, { level: "error" }); + } + }); +} + +const BaseWebhook = v.object({ + id: v.string(), + type: v.literal("spend"), + spend: v.object({ + amount: v.number(), + currency: v.literal("usd"), + cardId: v.string(), + localAmount: v.number(), + localCurrency: v.pipe(v.string(), v.length(3)), + merchantCity: v.nullish(v.pipe(v.string(), v.trim())), + merchantCountry: v.nullish(v.pipe(v.string(), v.trim())), + merchantCategory: v.nullish(v.pipe(v.string(), v.trim())), + merchantName: v.pipe(v.string(), v.trim()), + authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), + authorizedAmount: v.nullish(v.number()), + merchantId: v.nullish(v.string()), + }), +}); + +const Webhook = v.variant("resource", [ + v.variant("action", [ + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("created"), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + status: v.picklist(["pending", "declined"]), + declinedReason: v.nullish(v.string()), + }), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("updated"), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + authorizationUpdateAmount: v.number(), + authorizedAt: v.pipe(v.string(), v.isoTimestamp()), + status: v.picklist(["declined", "pending", "reversed"]), + declinedReason: v.nullish(v.string()), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + }), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("completed"), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + authorizedAt: v.pipe(v.string(), v.isoTimestamp()), + status: v.literal("completed"), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + }), + }), + }), + ]), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("card"), + action: v.literal("updated"), + body: v.object({ + id: v.string(), + last4: v.pipe(v.string(), v.length(4)), + limit: v.object({ + amount: v.number(), + frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]), + }), + status: v.picklist(["notActivated", "active", "locked", "canceled"]), + tokenWallets: v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))]), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("user"), + action: v.literal("updated"), + body: v.object({ + credentialId: v.string(), + applicationReason: v.string(), + applicationStatus: v.picklist([ + "approved", + "pending", + "needsInformation", + "needsVerification", + "manualReview", + "denied", + "locked", + "canceled", + ]), + isActive: v.boolean(), + }), + }), +]); + +const webhookConfig = v.object({ + type: v.picklist(["uphold"]), + secrets: v.record(v.string(), v.object({ key: v.string(), type: v.picklist(["HMAC-SHA256"]) })), + webhooks: v.record( + v.string(), + v.object({ + url: v.string(), + secretId: v.string(), + transaction: v.optional( + v.object({ + created: v.optional(v.string()), + updated: v.optional(v.string()), + completed: v.optional(v.string()), + }), + ), + card: v.optional(v.object({ updated: v.optional(v.string()) })), + user: v.optional(v.object({ updated: v.optional(v.string()) })), + }), + ), +}); diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 8a8f241c6..5da742691 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -12,7 +12,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } fr import deriveAddress from "@exactly/common/deriveAddress"; import app from "../../api/kyc"; -import database, { credentials } from "../../database"; +import database, { credentials, sources } from "../../database"; import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; import { scopeValidationErrors } from "../../utils/persona"; @@ -1050,6 +1050,19 @@ describe("authenticated", () => { }); describe("submit", () => { + beforeAll(async () => { + await database.insert(sources).values([ + { + id: "uphold", + config: { + type: "uphold", + secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, + webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + }, + }, + ]); + }); + it("returns ok when payload is valid and kyc is not started", async () => { await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 145a1c43d..425f38d3d 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -9,7 +9,8 @@ import "../mocks/sentry"; import { captureException, setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { parse } from "valibot"; +import { createHmac } from "node:crypto"; +import { object, parse, string } from "valibot"; import { BaseError, createWalletClient, @@ -38,14 +39,13 @@ import chain, { exaPluginAbi, issuerCheckerAbi, marketAbi, - marketUSDCAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import ProposalType from "@exactly/common/ProposalType"; import { Address, type Hash } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; -import database, { cards, credentials, transactions } from "../../database"; +import database, { cards, credentials, sources, transactions } from "../../database"; import app from "../../hooks/panda"; import keeper from "../../utils/keeper"; import * as panda from "../../utils/panda"; @@ -1582,7 +1582,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("over capture debit", async () => { + it("over-captures debit", async () => { const hold = 25; const capture = 30; @@ -1634,7 +1634,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("partial capture debit", async () => { + it("partial-captures debit", async () => { const hold = 80; const capture = 40; const cardId = "partial-capture-debit"; @@ -1686,7 +1686,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("force capture debit", async () => { + it("force-captures debit", async () => { const capture = 42; const cardId = "force-capture-debit"; @@ -1722,7 +1722,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("force capture fraud", async () => { + it("force-captures fraud", async () => { const updateUser = vi.spyOn(panda, "updateUser").mockResolvedValue(userResponseTemplate); const currentFunds = await publicClient .readContract({ @@ -1901,7 +1901,12 @@ describe("concurrency", () => { Promise.all([ keeper.exaSend( { name: "mint", op: "tx.mint" }, - { address: inject("USDC"), abi: mockERC20Abi, functionName: "mint", args: [account2, 70_000_000n] }, + { + address: inject("USDC"), + abi: mockERC20Abi, + functionName: "mint", + args: [account2, 70_000_000n], + }, ), keeper.exaSend( { name: "create account", op: "exa.account" }, @@ -1912,12 +1917,25 @@ describe("concurrency", () => { args: [0n, [{ x: hexToBigInt(owner2.account.address), y: 0n }]], }, ), - ]).then(() => - keeper.exaSend( - { name: "poke", op: "exa.poke" }, - { address: account2, abi: exaPluginAbi, functionName: "poke", args: [marketUSDCAddress] }, - ), - ), + ]) + .then(() => + keeper.writeContract({ + address: account2, + abi: exaPluginAbi, + functionName: "poke", + args: [inject("MarketUSDC")], + }), + ) + .then(async (hash) => { + const { status } = await publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }); + if (status !== "success") { + const trace = await traceClient.traceTransaction(hash); + const error = new Error(trace.output); + captureException(error, { contexts: { tx: { trace } } }); + Object.assign(error, { trace }); + throw error; + } + }), ]); }); @@ -2008,7 +2026,7 @@ describe("concurrency", () => { afterEach(() => vi.useRealTimers()); - it("mutex timeout", async () => { + it("times out when mutex is locked", async () => { const getMutex = vi.spyOn(panda, "getMutex"); const cardId = `${account2}-card`; const promises = Promise.all([ @@ -2061,6 +2079,232 @@ describe("concurrency", () => { }); }); +describe("webhooks", () => { + let webhookOwner: WalletClient, typeof chain, ReturnType>; + let webhookAccount: Address; + + beforeAll(async () => { + webhookOwner = createWalletClient({ + chain, + transport: http(), + account: privateKeyToAccount(generatePrivateKey()), + }); + webhookAccount = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(webhookOwner.account.address), + y: zeroHash, + }); + await Promise.all([ + database.insert(sources).values([ + { + id: "test", + config: { + type: "uphold", + secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, + webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + }, + }, + ]), + database + .insert(credentials) + .values([ + { + id: webhookAccount, + publicKey: new Uint8Array(), + account: webhookAccount, + factory: zeroAddress, + source: "test", + pandaId: webhookAccount, + }, + ]) + .then(() => { + return database + .insert(cards) + .values([{ id: `${webhookAccount}-card`, credentialId: webhookAccount, lastFour: "1234", mode: 0 }]); + }), + + anvilClient.setBalance({ address: webhookOwner.account.address, value: 10n ** 24n }), + Promise.all([ + keeper.exaSend( + { name: "mint", op: "tx.mint" }, + { + address: inject("USDC"), + abi: mockERC20Abi, + functionName: "mint", + args: [webhookAccount, 50_000_000n], + }, + ), + keeper.exaSend( + { name: "create account", op: "exa.account" }, + { + address: inject("ExaAccountFactory"), + abi: exaAccountFactoryAbi, + functionName: "createAccount", + args: [0n, [{ x: hexToBigInt(webhookOwner.account.address), y: 0n }]], + }, + ), + ]) + .then(() => + keeper.writeContract({ + address: webhookAccount, + abi: exaPluginAbi, + functionName: "poke", + args: [inject("MarketUSDC")], + }), + ) + .then(async (hash) => { + const { status } = await publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }); + if (status !== "success") { + const trace = await traceClient.traceTransaction(hash); + const error = new Error(trace.output); + captureException(error, { contexts: { tx: { trace } } }); + Object.assign(error, { trace }); + throw error; + } + }), + ]); + }); + + afterEach(() => vi.resetAllMocks()); + + it("forwards transaction created", async () => { + const cardId = `${webhookAccount}-card`; + + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: cardId, + spend: { ...transactionCreated.json.body.spend, cardId, userId: webhookAccount }, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards transaction updated", async () => { + vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); + const cardId = `${webhookAccount}-card`; + + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await appClient.index.$post({ + ...transactionUpdated, + json: { + ...transactionUpdated.json, + body: { + ...transactionUpdated.json.body, + id: cardId, + spend: { ...transactionUpdated.json.body.spend, cardId, userId: webhookAccount }, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards transaction completed", async () => { + vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); + const cardId = `${webhookAccount}-card`; + + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await appClient.index.$post({ + ...transactionCompleted, + json: { + ...transactionCompleted.json, + body: { + ...transactionCompleted.json.body, + id: cardId, + spend: { ...transactionCompleted.json.body.spend, cardId, userId: webhookAccount }, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 1, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards card updated", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json() { + return Promise.resolve({}); + }, + } as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards user updated", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json() { + return Promise.resolve({}); + }, + } as Response); + + await appClient.index.$post({ + ...userUpdated, + json: { + ...userUpdated.json, + body: { + ...userUpdated.json.body, + id: webhookAccount, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); +}); + const authorization = { header: { signature: "panda-signature" }, json: { @@ -2083,6 +2327,7 @@ const authorization = { merchantCity: "buenos aires", merchantCountry: "AR", merchantName: "99999", + merchantId: "550e8400-e29b-41d4-a716-446655440000", status: "pending", userEmail: "mail@mail.com", userFirstName: "David", @@ -2093,6 +2338,170 @@ const authorization = { }, } as const; +const cardUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "31740000-bd68-40c8-a400-5a0131f58800", + resource: "card", + action: "updated", + body: { + id: "f3d8a9c2-4e7b-4a1c-9f2e-8d5c6b3a7e9f", + userId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "7392", + expirationMonth: "11", + expirationYear: "2029", + tokenWallets: ["Apple"], + }, + }, +} as const; + +const userUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + resource: "user", + action: "updated", + body: { + id: "0e3c467c-01e3-4fe8-8778-1c88e02fd000", + firstName: "David", + lastName: "Mayer", + email: "mail@mail.com", + isActive: true, + isTermsOfServiceAccepted: true, + applicationStatus: "pending", + applicationExternalVerificationLink: { + url: "https://cardmemberportal.com/kyc", + params: { + userId: "0e3c467", + signature: "CiQAmdPUf", + }, + }, + applicationCompletionLink: { + url: "https://cardmemberportal.com/kyc", + params: { + userId: "0e3c467", + signature: "CiQAmdPUf", + }, + }, + applicationReason: "COMPROMISED_PERSONS, PEP", + }, + }, +} as const; + +const transactionCreated = { + header: { signature: "panda-signature" }, + json: { + id: "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", + body: { + id: "4e19a38e-3161-4db1-ac91-e12630950e2c", + type: "spend", + spend: { + amount: -10_000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "pending", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + userEmail: "rain@gmail.com", + merchantId: "297f8888-55b4-57df-a55b-800c61a3207b", + localAmount: -10_000, + authorizedAt: "2025-07-03T19:52:59.806Z", + merchantCity: "New York ", + merchantName: "Test Refund ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "Rain", + merchantCountry: "US", + authorizedAmount: -10_000, + merchantCategory: "5641 - Children's and Infant's Wear Store", + authorizationMethod: "Normal presentment", + merchantCategoryCode: "5641", + }, + }, + action: "created", + resource: "transaction", + }, +} as const; + +const transactionUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "e7b2853e-4bb7-4428-8dc2-27e604766dfa", + body: { + id: "30dcf8c6-a1e5-48f1-9c40-ecffe8253d25", + type: "spend", + spend: { + amount: 8000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "reversed", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + userEmail: "zjdnflol@gamil.com", + merchantId: "d0a30859-096d-57f4-bffd-fd745f44e048", + localAmount: 8000, + authorizedAt: "2025-06-25T15:24:11.337Z", + merchantCity: " ", + merchantName: "Test ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "jason", + merchantCountry: " ", + authorizedAmount: 8000, + merchantCategory: " - ", + authorizationMethod: "Normal presentment", + enrichedMerchantName: "Test", + merchantCategoryCode: "", + enrichedMerchantCategory: "Education", + authorizationUpdateAmount: -2000, + }, + }, + action: "updated", + resource: "transaction", + }, +} as const; + +const transactionCompleted = { + header: { signature: "panda-signature" }, + json: { + id: "77474a56-51eb-4918-b09e-73cf20077b1b", + body: { + id: "4e19a38e-3161-4db1-ac91-e12630950e2c", + type: "spend", + spend: { + amount: -10_000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "completed", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + postedAt: "2025-07-03T19:57:04.332Z", + userEmail: "rain@gmail.com", + localAmount: -10_000, + authorizedAt: "2025-07-03T19:52:59.806Z", + merchantCity: "New York ", + merchantName: "Test Refund ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "Rain", + merchantCountry: "US", + authorizedAmount: -10_000, + merchantCategory: "Children's and Infant's Wear Store", + authorizationMethod: "Normal presentment", + enrichedMerchantName: "Test Refund", + merchantCategoryCode: "5641", + enrichedMerchantCategory: "Refunds - Insufficient Funds", + merchantId: "297f8888-55b4-57df-a55b-800c61a3207b", + }, + }, + action: "completed", + resource: "transaction", + }, +} as const; + const receipt = { status: "success", blockHash: zeroHash, From 9231f26be6ff6ca76166093a5e39344575fa9e4a Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Wed, 13 Aug 2025 22:36:45 +0200 Subject: [PATCH 05/84] =?UTF-8?q?=F0=9F=93=9D=20docs:=20document=20server?= =?UTF-8?q?=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/astro.config.ts | 2 +- docs/src/content/docs/webhooks.md | 611 ++++++++++++++++++++++++++++++ 2 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 docs/src/content/docs/webhooks.md diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 5c83c0ebe..df691a810 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ { base: "api", schema: "node_modules/@exactly/server/generated/openapi.json", sidebar: { collapsed: false } }, ]), ], - sidebar: openAPISidebarGroups, + sidebar: [{ label: "Docs", items: ["index", "webhooks"] }, ...openAPISidebarGroups], }), mermaid(), ], diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md new file mode 100644 index 000000000..fd806f868 --- /dev/null +++ b/docs/src/content/docs/webhooks.md @@ -0,0 +1,611 @@ +--- +title: Webhooks +sidebar: + label: Webhooks + order: 10 +--- + +Webhooks enable real-time event notifications, allowing you to integrate external systems with Exa. + +## Setting up webhooks + +A default endpoint can be configured and optionally an endpoint for each of the 5 event types: + +- Transaction created +- Transaction updated +- Transaction completed +- User updated +- Card updated + +## Webhook security and signing + +Each webhook request is signed using an HMAC SHA256 signature, based on the exact JSON payload sent in the body. This signature is included in the Signature HTTP header of the request. + +You can verify webhook authenticity by computing the HMAC signature and comparing it to the `Signature` header included in the webhook request. + +Example: Verifying a webhook signature (Node.js) + +```typescript +import { createHmac } from "crypto"; + +const signature = createHmac("sha256", ) + .update() // JSON.stringify(payload) + .digest("hex"); +``` + +Ensure that the computed signature matches the Signature header received in the webhook request before processing the payload. + +## Retry policy and timeout + +An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 200 or times out. + +| Retry Count | Delay (ms) | Delay (seconds) | Delay (minutes) | +| --- | --- | --- | --- | +| 0 | 500 | 0.5s | - | +| 1 | 1,000 | 1s | - | +| 2 | 2,000 | 2s | - | +| 3 | 4,000 | 4s | - | +| 4 | 8,000 | 8s | - | +| 5 | 16,000 | 16s | - | +| ..... | | | | +| 16 | 32,768,000 | 32768s | ~546.1min | +| 17 | 65,536,000 | 65536s | ~1092.3min | +| 18 | 131,072,000 | 131072s | ~2184.5min | +| 19 | 262,144,000 | 262144s | ~4369.1min | + +## Webhook flows + +There are 5 different types of flow that uses different events which details are in the `Event reference` section: + +- Purchase lifecycle with settlement +- Partial capture +- Over capture +- Force capture +- Refund + +### Purchase lifecycle with settlement + +This example demonstrates a complete transaction lifecycle through webhook notifications, showing how a transaction progresses from initial transaction created to final settlement with an amount adjustment. + +```mermaid +sequenceDiagram + participant Merchant + participant Exa + participant Blockchain + participant Uphold + + + Merchant->>Exa: auth request ($100) + activate Exa + + Exa->>Blockchain: simulate collect ($100) + activate Blockchain + Note over Blockchain: total collect simulation ($100) + Blockchain-->>Exa: simulation OK + deactivate Blockchain + Exa-->>Merchant: auth approved + + Exa->>Blockchain: collect ($100) + activate Blockchain + Note over Blockchain: total collect ($100) + Blockchain-->>Exa: Transaction hash + deactivate Blockchain + + deactivate Exa + + Exa->>Uphold: transaction.created webhook ($100) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + + Note over Merchant,Uphold: Time passes (usually same day) + + + Merchant->>Exa: reversal request (-20) + activate Exa + + Exa->>Blockchain: Refund ($20) + activate Blockchain + Note over Blockchain: Refund + Blockchain-->>Exa: Transaction hash + deactivate Blockchain + Exa-->>Merchant: reversal approved + + Exa->>Uphold: webhook transaction.updated (-20) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + + deactivate Exa + Note over Merchant,Uphold: Time passes (usually 3 business days) + + Exa->>Uphold: webhook transaction.completed (80) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + +``` + +#### Transaction Created + +Transaction authorized and created with timestamp, for $100.00 amount. + +```typescript +{ + "id": "99493687-78c1-4018-8831-d8b1f66f58e2", + "timestamp": "2025-08-13T14:36:04.586Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Updated + +Amount adjusted from $100.00 to $80.00 with status "reversed" and authorizationUpdateAmount of -$20.00 +Note that this is a reversal, 1 of the 3 types of refunds. + +```typescript +{ + "id": "e7b2853e-4bb7-4428-8dc2-27e604766dfa", + "timestamp": "2025-08-12T20:08:37.707Z", + "resource": "transaction", + "action": "updated", + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 8000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 8000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 8000, + "authorizationUpdateAmount": -2000, + "status": "reversed", + "enrichedMerchantName": "Test", + "enrichedMerchantCategory": "Education" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $80.00 with status "completed". + +```typescript +{ + "id": "662eb701-f9ac-4baa-9f86-b341a730c98a", + "timestamp": "2025-08-12T20:23:20.662Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 8000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 8000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 8000, + "status": "completed", + "enrichedMerchantName": "Test", + "enrichedMerchantCategory": "Education" + } + } +} +``` + +### Partial capture flow + +In a partial capture, the merchant settles for less than the authorized amount. After receiving the transaction completed webhook, the over authorized and captured funds are released to the user. This flow is common in restaurants, where the final charge may be lower than the original authorization after accounting for tips. + +#### Transaction Created + +Transaction authorized and created with timestamp for $100.00 amount. + +```typescript +{ + "id": "99493687-78c1-4018-8831-d8b1f66f58e2", + "timestamp": "2025-08-13T16:37:08.862Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $90.00 with status "completed" and timestamp. The final amount is $90 and previously $100 was authorized and captured to the user so $10 is refunded. This is one of the 3 types of refunds. + +```typescript +{ + "id": "a79306b2-bbbc-4511-9e58-ca9fbc9a2d9a", + "timestamp": "2025-08-13T16:42:28.955Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 9000, // notice the partial capture + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 9000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5511", + "merchantName": "PartialCapture Example", + "authorizedAt": "2025-07-03T18:40:28.024Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Partial capture Example", + "enrichedMerchantCategory": "Business - Software" + } + } +} +``` + +### Over Capture + +In an over capture, the merchant settles for more than the originally authorized amount. This flow is typically used in scenarios that involve tips or additional surcharges, such as dining or hospitality. +Certain industries, like restaurants and bars, are allowed to settle for more than the authorized amount—typically up to 20%—to accommodate tips and similar charges. + +#### Transaction Created + +Transaction authorized and created with timestamp for $100.00 amount. + +```typescript +{ + "id": "9d96c8c9-d10f-4d3a-90b9-978eca13ae2a", + "timestamp": "2025-08-13T16:53:21.455Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5812 - Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $110.00 with status "completed" and timestamp. Note that the final amount is 110 but 100 was authorized and captured so capturing an extra $10 to the user is needed. + +```typescript +{ + "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", + "timestamp": "2025-08-13T16:55:11.934Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 11000, // notice the increase in the amount of settlement + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 11000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Over Capture Example", + "enrichedMerchantCategory": "Restaurants" + } + } +} +``` + +### Force Capture + +A force capture occurs when a merchant settles a transaction without prior authorization. These transactions bypass the authorization phase and proceed directly to settlement. This flow is typically used in offline scenarios, such as in-flight purchases where the merchant does not have internet access. + +#### Transaction completed + +```typescript +{ + "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", + "timestamp": "2025-08-13T17:00:08.061Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", + "type": "spend", + "spend": { + "amount": 11000, + "currency": "usd", + "cardId": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", + "localAmount": 11000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Over Capture Example", + "enrichedMerchantCategory": "Restaurants" + } + } +} +``` + +### Refund + +Refunds are treated as negative transactions and may or may not reference the original transaction completed. Unlike reversals, refunds can be initiated independently of the original transaction and may occur well after the initial settlement. + +#### Transaction created + +The webhook is only for informational purpose, Exa does not return funds to the user with this event, is just to notify that a proper refund is coming and +do sanity checks. + +```typescript +{ + "id": "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", + "timestamp": "2025-08-13T17:08:50.609Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": -10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": -10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5641 - Children's and Infant's Wear Store", + "merchantName": "Test Refund", + "authorizedAt": "2025-07-03T19:52:59.806Z", + "authorizedAmount": -10000, + "status": "pending" + } + } +} + ``` + +#### Transaction Completed + +Final settlement of -$100.00 with status "completed" and timestamp. Refund $100 to the user. + +```typescript +{ + "id": "77474a56-51eb-4918-b09e-73cf20077b1b", + "timestamp": "2025-08-13T17:12:48.858Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": -10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": -10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Children's and Infant's Wear Store", + "merchantName": "Test Refund", + "authorizedAt": "2025-07-03T19:52:59.806Z", + "authorizedAmount": -10000, + "status": "completed", + "enrichedMerchantName": "Test Refund", + "enrichedMerchantCategory": "Refunds - Insufficient Funds" + } + } +} +``` + +## Refunds + +There are 3 types of operations that return funds to the user: reversal, partial capture, and refund. + +### Reversal + +This occurs when the user calls an uber, for example. Authorizes $30 but then the travel is cancelled, so exa instantly return the funds to the user in a $30 reversal. This happens before the settlement and can happen many times. Timing: reversals are usually during the same day. + +#### Partial capture + +This happens when a transaction enters a terminal state, which means no more reversals or other event types are allowed. This is the last event. If the authorized amount is higher than the final amount, funds need to be returned to the user. This looks pretty much like a reversal but also signals to the user that no more assets will be requested or returned as part of the purchase flow. Timing: usually 2 or 3 business days after swiping the card. + +#### Refund + +Refunds come after the purchase enters a terminal state and could be associated with the purchase or not. That is not guaranteed, but if it is not the same, using the merchant name to link is suggested. Timing: more than a week. + +| Operation | Display | Time | +| --- | --- | --- | +| reversal | purchase details | same day | +| partial | purchase details | 2 or 3 business day | +| refunds | activity | weeks | + +## Event reference + +### Transaction created event + +The transaction created webhook is sent when the transaction flow is created, whether it has been authorized or declined. You must persist this information. +This event initiates the purchase lifecycle in case of `pending`, then could exist many intermediate state changes done by `transaction update` event and finally the `transaction complete` event sets the purchase in terminal state. No more events coming except of a refund which transaction id could be the same as the original purchase or not. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhookId and always the same when retry | 372d1a76-8a57-403e-a7f3-ac3231be144c | +| timestamp | string | Time when sent the event. Always the same when retry | 2025-08-06T20:29:23.870Z | +| resource | "transaction" | | transaction | +| action | "created" | | created | +| body.id | string | Transaction id. Is the same for many events in the life cycle of the purchase | f1083e93-afd5-4271-85c6-dd47099e9746 | +| body.type | "spend" | | spend | +| body.spend.amount | integer | Amount of the purchase in USD in cents. 1 USD = 100 | 100 | +| body.spend.currency | string | Always in usd | usd | +| body.spend.cardId | string | | 47c3c3b3-b197-4a97-ace3-901a6ad7cf61 | +| body.spend.localAmount | integer | Purchase amount in local currency | 100 | +| body.spend.localCurrency | string | The local currency | usd, eur, ars | +| body.spend.merchantCity? | string | The merchant city | "San Francisco" | +| body.spend.merchantCountry? | string | The merchant country | "US" | +| body.spend.merchantCategory? | string | The merchant category | "5814 - Quick Payment Service-Fast Food Restaurants" | +| body.spend.merchantName | string | The merchant name | SQ *BLUE BOTTLE COFFEE | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.authorizedAt | string | Time when purchase was authorized in ISO 8601 | 2025-08-06T20:29:23.288Z | +| body.spend.authorizedAmount | integer | The authorized amount | 100 | +| body.spend.status | "pending" \| "declined" | Can be pending or declined. In case of declined, the field `declinedReason` has the reason | pending | +| body.spend.declinedReason? | string | Decline message | webhook declined | + +### Transaction updated event + +This webhook is sent whenever a transaction is updated. Note that the transaction may not have been created before this update. +Triggered for events such as incremental authorizations or reversals (a type of refund). + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | e972a2b0-a990-47af-b460-500ff75fbf65 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-11T15:30:39.939Z | +| resource | "transaction" | | transaction | +| action | "updated" | | updated | +| body.id | string | transaction id. the same in the life cycle of the purchase | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | +| body.type | "spend" | | spend | +| body.spend.amount | number | amount in usd authorized | 2499 | +| body.spend.currency | string | always dollars | usd | +| body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.spend.localAmount | number | amount in local currency authorized | 2499 | +| body.spend.localCurrency | string | currency of the purchase | usd | +| body.spend.merchantCity? | string | city of the merchant | SAN FRANCISCO | +| body.spend.merchantCountry? | string | country of the merchant | US | +| body.spend.merchantCategory? | string | category of the merchant | 4121 - Taxicabs and Limousines | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.merchantName | string | name of the merchant | UBER *TRIP | +| body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-10T04:28:39.547Z | +| body.spend.authorizedAmount | number | amount authorized | 2499 | +| body.spend.authorizationUpdateAmount | number | amount difference authorized. it can be positive in case of status pending or negative if is a reversal. will be declined if was not possible to authorize the increment or decrement of the authorization | 726 | +| body.spend.status | "pending" \| "reversed" \| "declined" | current status of the transaction | pending | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | +| body.spend.enrichedMerchantName? | string | name of the enriched merchant | Uber | +| body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Transport - Rides | + +### Transaction completed event + +This webhook is sent whenever a transaction reaches a final state. Note that the transaction may not have been created before this update. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | 662eb701-f9ac-4baa-9f86-b341a730c6dc | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:29:20.499Z | +| resource | "transaction" | | transaction | +| action | "completed" | | completed | +| body.id | string | Is the Transaction id and is the same in the life cycle of the purchase. With refunds could be different from the original purchase. | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | +| body.type | "spend" | | spend | +| body.spend.amount | number | final settled amount in usd | 1041 | +| body.spend.currency | string | always dollars | usd | +| body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.spend.localAmount | number | final settled amount in local currency | 1270000 | +| body.spend.localCurrency | string | currency of the purchase | ars | +| body.spend.merchantCity? | string | city of the merchant | CAP.FEDERAL | +| body.spend.merchantCountry? | string | country of the merchant | AR | +| body.spend.merchantCategory? | string | category of the merchant | Recreation Services | +| body.spend.merchantName | string | name of the merchant | JOCKEY CLUB | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-08T17:55:14.312Z | +| body.spend.authorizedAmount | number | original authorized amount | 1035 | +| body.spend.status | "completed" | final status of the transaction | completed | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | +| body.spend.enrichedMerchantName? | string | name of the enriched merchant | Jockey | +| body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Shopping | + +### User updated + +This webhook is sent whenever a user's compliance status is updated. No response is required. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | bdc87700-bf6d-4d7d-ac29-3effb06e3000 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T19:16:56.709Z | +| resource | "user" | | user | +| action | "updated" | | updated | +| body.credentialId | string | credential id | 0xE18847D2f02cE2800C07c5b42e66c819eC78d35f | +| body.applicationReason | string | reason for application status | COMPROMISED_PERSONS, PEP | +| body.applicationStatus | "approved" \| "pending" \| "needsInformation" \| "needsVerification" \| "manualReview" \| "denied" \| "locked" \| "canceled" | current status of the application | pending | +| body.isActive | boolean | whether the user is active | true | + +### Card updated + +This webhook is currently triggered when a user adds their card to a digital wallet. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | 31740000-bd68-40c8-a400-5a0131f58800 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:47:33.687Z | +| resource | "card" | | card | +| action | "updated" | | updated | +| body.id | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.last4 | string | last 4 digits of the card | 7392 | +| body.limit.amount | number | spending limit amount | 1000000 | +| body.limit.frequency | "per24HourPeriod" \| "per7DayPeriod" \| "per30DayPeriod" \| "perYearPeriod" | frequency of the spending limit | per7DayPeriod | +| body.status | "notActivated" \| "active" \| "locked" \| "canceled" | current status of the card | active | +| body.tokenWallets | ["Apple"] \| ["Google Pay"] | array of token wallets | ["Apple"] | From ca6e451d88717c5959afad087cf464bac070b959 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 9 Sep 2025 15:12:18 -0300 Subject: [PATCH 06/84] =?UTF-8?q?=F0=9F=A9=B9=20server:=20return=20correct?= =?UTF-8?q?=20card=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/gold-ties-tan.md | 5 +++++ server/utils/panda.ts | 4 +--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 .changeset/gold-ties-tan.md diff --git a/.changeset/gold-ties-tan.md b/.changeset/gold-ties-tan.md new file mode 100644 index 000000000..fa6728903 --- /dev/null +++ b/.changeset/gold-ties-tan.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🩹 return correct card status diff --git a/server/utils/panda.ts b/server/utils/panda.ts index 34fe92e4e..6bd3d7952 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -244,13 +244,11 @@ const CreateCardRequest = object({ configuration: object({ productId: picklist([PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), virtualCardArt: string() }), }); -export const CardStatus = picklist(["active", "canceled", "locked", "notActivated"]); - const CardResponse = object({ id: string(), userId: string(), type: literal("virtual"), - status: CardStatus, + status: picklist(["active", "canceled", "locked", "notActivated"]), limit: object({ amount: number(), frequency: picklist([ From 1ea2ba3bf7dec2b34b710df57a9a924a82f5b424 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 10 Sep 2025 10:28:14 -0300 Subject: [PATCH 07/84] =?UTF-8?q?=F0=9F=93=9D=20server:=20add=20sandbox=20?= =?UTF-8?q?dedicated=20ips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/long-nails-doubt.md | 5 +++++ docs/src/content/docs/webhooks.md | 9 +++++++++ 2 files changed, 14 insertions(+) create mode 100644 .changeset/long-nails-doubt.md diff --git a/.changeset/long-nails-doubt.md b/.changeset/long-nails-doubt.md new file mode 100644 index 000000000..43f46804d --- /dev/null +++ b/.changeset/long-nails-doubt.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +📝 add sandbox dedicated ips diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index fd806f868..0d3143bf5 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -35,6 +35,15 @@ const signature = createHmac("sha256", ) Ensure that the computed signature matches the Signature header received in the webhook request before processing the payload. +## Exa server IPs + +IP allow listing prevents spoofing, reduces attack surface, and adds network-level security to webhooks. + +### Sandbox + +- 209.38.69.78 +- 143.198.79.59 + ## Retry policy and timeout An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 200 or times out. From 3f441c698b26cceb1aaa246836f31d4e9c475630 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Mon, 20 Oct 2025 17:35:26 -0300 Subject: [PATCH 08/84] =?UTF-8?q?=E2=9E=95=20server:=20install=20better-au?= =?UTF-8?q?th?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/pretty-chicken-hang.md | 5 + pnpm-lock.yaml | 291 +++++++++++++++++++++++++++++- server/package.json | 2 + 3 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 .changeset/pretty-chicken-hang.md diff --git a/.changeset/pretty-chicken-hang.md b/.changeset/pretty-chicken-hang.md new file mode 100644 index 000000000..455b0e41f --- /dev/null +++ b/.changeset/pretty-chicken-hang.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +➕ install better-auth diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75070def3..8aa57edbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -762,6 +762,9 @@ importers: async-mutex: specifier: ^0.5.0 version: 0.5.0 + better-auth: + specifier: ^1.4.18 + version: 1.4.18(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1))(pg@8.17.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.17) bullmq: specifier: ^5.66.5 version: 5.66.5 @@ -770,7 +773,7 @@ importers: version: 4.4.3 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.17.1) + version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1) graphql: specifier: ^16.12.0 version: 16.12.0 @@ -841,6 +844,9 @@ importers: '@wagmi/core': specifier: ^3.2.2 version: 3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) + better-sqlite3: + specifier: ^12.6.2 + version: 12.6.2 drizzle-kit: specifier: ^0.31.8 version: 0.31.8 @@ -1673,6 +1679,27 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@better-auth/core@1.4.18': + resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-call: 1.1.8 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.18': + resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} + peerDependencies: + '@better-auth/core': 1.4.18 + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -3679,6 +3706,10 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + '@noble/curves@1.9.1': resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} engines: {node: ^14.21.3 || >=16} @@ -6770,6 +6801,76 @@ packages: peerDependencies: ajv: 4.11.8 - 8 + better-auth@1.4.18: + resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: '>=0.41.0' + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.1.8: + resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} @@ -6778,6 +6879,10 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -6785,6 +6890,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + birecord@0.1.1: resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==} @@ -7006,6 +7114,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -8432,6 +8543,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -8780,6 +8895,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -9036,6 +9154,9 @@ packages: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -9964,6 +10085,10 @@ packages: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} + engines: {node: '>=20.0.0'} + lan-network@0.1.7: resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==} hasBin: true @@ -10683,6 +10808,9 @@ packages: typescript: optional: true + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -10743,6 +10871,13 @@ packages: nanospinner@1.2.2: resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -11344,6 +11479,12 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -11436,6 +11577,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -11986,6 +12130,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -12096,6 +12243,9 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -12191,6 +12341,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -12532,6 +12688,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -12706,6 +12865,9 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -14632,6 +14794,27 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.1.0 + better-call: 1.1.8(zod@4.3.5) + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.5 + + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.21': {} + '@braintree/sanitize-url@7.1.1': {} '@bufbuild/buf-darwin-arm64@1.63.0': @@ -16885,6 +17068,8 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/ciphers@2.1.1': {} + '@noble/curves@1.9.1': dependencies: '@noble/hashes': 1.8.0 @@ -21254,6 +21439,38 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + better-auth@1.4.18(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1))(pg@8.17.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.17): + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.1.8(zod@4.3.5) + defu: 6.1.4 + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.5 + optionalDependencies: + better-sqlite3: 12.6.2 + drizzle-kit: 0.31.8 + drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1) + pg: 8.17.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@25.0.9)(@vitest/ui@4.0.17)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + better-call@1.1.8(zod@4.3.5): + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 2.7.2 + optionalDependencies: + zod: 4.3.5 + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -21262,12 +21479,21 @@ snapshots: dependencies: is-windows: 1.0.2 + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 big-integer@1.6.52: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + birecord@0.1.1: {} bl@4.1.0: @@ -21527,6 +21753,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} chrome-launcher@0.15.2: @@ -22317,10 +22545,12 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.17.1): + drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1): optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/pg': 8.16.0 + better-sqlite3: 12.6.2 + kysely: 0.28.11 pg: 8.17.1 dset@3.1.4: {} @@ -23293,6 +23523,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expect-type@1.3.0: {} expo-application@7.0.8(expo@54.0.31): @@ -23727,6 +23959,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + filelist@1.0.4: dependencies: minimatch: 5.1.6 @@ -24005,6 +24239,8 @@ snapshots: getenv@2.0.0: {} + github-from-package@0.0.0: {} + github-slugger@2.0.0: {} gitmojis@3.15.0: {} @@ -25072,6 +25308,8 @@ snapshots: klona@2.0.6: {} + kysely@0.28.11: {} + lan-network@0.1.7: {} langium@4.2.1: @@ -26317,6 +26555,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} mlly@1.8.0: @@ -26380,6 +26620,10 @@ snapshots: dependencies: picocolors: 1.1.1 + nanostores@1.1.0: {} + + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -27046,6 +27290,21 @@ snapshots: dependencies: xtend: 4.0.2 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.86.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -27136,6 +27395,11 @@ snapshots: proxy-from-env@1.1.0: {} + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -27849,6 +28113,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.2 fsevents: 2.3.3 + rou3@0.7.12: {} + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -27995,6 +28261,8 @@ snapshots: set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -28160,6 +28428,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -28595,6 +28871,13 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -28757,6 +29040,10 @@ snapshots: dependencies: tslib: 1.14.1 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/server/package.json b/server/package.json index f5a438faf..714f752f8 100644 --- a/server/package.json +++ b/server/package.json @@ -46,6 +46,7 @@ "@valibot/to-json-schema": "^1.5.0", "async-mutex": "^0.5.0", "bullmq": "^5.66.5", + "better-auth": "^1.4.18", "debug": "^4.4.3", "drizzle-orm": "^0.45.1", "graphql": "^16.12.0", @@ -73,6 +74,7 @@ "@vitest/coverage-v8": "^4.0.17", "@vitest/ui": "^4.0.17", "@wagmi/core": "catalog:", + "better-sqlite3": "^12.6.2", "drizzle-kit": "^0.31.8", "embedded-postgres": "^18.1.0-beta.15", "eslint": "^9.39.2", From 956068946f0272c7c744779335024c5a6fed7b4c Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Fri, 12 Dec 2025 15:36:59 -0300 Subject: [PATCH 09/84] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20server:=20setup?= =?UTF-8?q?=20better-auth=20database=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cool-snakes-reply.md | 5 + server/database/schema.ts | 196 ++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 .changeset/cool-snakes-reply.md diff --git a/.changeset/cool-snakes-reply.md b/.changeset/cool-snakes-reply.md new file mode 100644 index 000000000..3e43d51ee --- /dev/null +++ b/.changeset/cool-snakes-reply.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🗃️ setup better-auth database tables diff --git a/server/database/schema.ts b/server/database/schema.ts index 6274e0c24..40835c4e8 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -1,8 +1,10 @@ import { relations } from "drizzle-orm"; import { bigint, + boolean, char, customType, + index, integer, jsonb, pgEnum, @@ -11,6 +13,7 @@ import { primaryKey, serial, text, + timestamp, uniqueIndex, } from "drizzle-orm/pg-core"; @@ -107,3 +110,196 @@ export const exaPlugins = substreams.table( }, ({ address, account }) => [primaryKey({ columns: [address, account] })], ); + +export const sourcesRelations = relations(sources, ({ many }) => ({ credential: many(credentials) })); + +export const users = pgTable("users", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const sessions = pgTable( + "sessions", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + activeOrganizationId: text("active_organization_id"), + }, + (table) => [index("sessions_user_idx").on(table.userId)], +); + +export const authenticators = pgTable( + "authenticators", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("authenticators_user_idx").on(table.userId)], +); + +export const verifications = pgTable( + "verifications", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verifications_identifier_idx").on(table.identifier)], +); + +export const walletAddresses = pgTable( + "wallet_addresses", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + address: text("address").notNull(), + chainId: integer("chain_id").notNull(), + isPrimary: boolean("is_primary").default(false), + createdAt: timestamp("created_at").notNull(), + }, + (table) => [index("wallet_addresses_user_idx").on(table.userId)], +); + +export const organizations = pgTable("organizations", { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + createdAt: timestamp("created_at").notNull(), + metadata: text("metadata"), +}); + +export const members = pgTable( + "members", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + role: text("role").default("member").notNull(), + createdAt: timestamp("created_at").notNull(), + }, + (table) => [index("members_organization_idx").on(table.organizationId), index("members_user_idx").on(table.userId)], +); + +export const invitations = pgTable( + "invitations", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: text("role"), + status: text("status").default("pending").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + inviterId: text("inviter_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => [ + index("invitations_organization_idx").on(table.organizationId), + index("invitations_email_idx").on(table.email), + ], +); + +export const usersRelations = relations(users, ({ many }) => ({ + sessions: many(sessions), + authenticators: many(authenticators), + walletAddresses: many(walletAddresses), + members: many(members), + invitations: many(invitations), +})); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.userId], + references: [users.id], + }), +})); + +export const authenticatorsRelations = relations(authenticators, ({ one }) => ({ + user: one(users, { + fields: [authenticators.userId], + references: [users.id], + }), +})); + +export const walletAddressesRelations = relations(walletAddresses, ({ one }) => ({ + user: one(users, { + fields: [walletAddresses.userId], + references: [users.id], + }), +})); + +export const organizationsRelations = relations(organizations, ({ many }) => ({ + members: many(members), + invitations: many(invitations), +})); + +export const membersRelations = relations(members, ({ one }) => ({ + organization: one(organizations, { + fields: [members.organizationId], + references: [organizations.id], + }), + user: one(users, { + fields: [members.userId], + references: [users.id], + }), +})); + +export const invitationsRelations = relations(invitations, ({ one }) => ({ + organization: one(organizations, { + fields: [invitations.organizationId], + references: [organizations.id], + }), + user: one(users, { + fields: [invitations.inviterId], + references: [users.id], + }), +})); From ec13a805a3b0717e7d693b67a090f7a1dece8283 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 10 Sep 2025 16:58:25 -0300 Subject: [PATCH 10/84] =?UTF-8?q?=E2=9C=A8=20server:=20add=20webhook=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/olive-onions-tan.md | 5 + docs/astro.config.ts | 5 +- .../docs/organization-authentication.md | 222 ++++++++++++++ server/api/index.ts | 4 +- server/api/kyc.ts | 2 +- server/api/webhook.ts | 271 ++++++++++++++++++ server/database/index.ts | 19 +- server/hooks/panda.ts | 10 +- server/index.ts | 3 + server/middleware/auth.ts | 10 +- server/script/openapi.ts | 5 +- server/test/api/kyc.test.ts | 2 +- server/test/api/webhook.test.ts | 209 ++++++++++++++ server/test/hooks/panda.test.ts | 16 +- server/utils/auth.ts | 62 ++++ 15 files changed, 822 insertions(+), 23 deletions(-) create mode 100644 .changeset/olive-onions-tan.md create mode 100644 docs/src/content/docs/organization-authentication.md create mode 100644 server/api/webhook.ts create mode 100644 server/test/api/webhook.test.ts create mode 100644 server/utils/auth.ts diff --git a/.changeset/olive-onions-tan.md b/.changeset/olive-onions-tan.md new file mode 100644 index 000000000..ff26342e1 --- /dev/null +++ b/.changeset/olive-onions-tan.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add webhook api diff --git a/docs/astro.config.ts b/docs/astro.config.ts index df691a810..4ffd52f66 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -15,7 +15,10 @@ export default defineConfig({ { base: "api", schema: "node_modules/@exactly/server/generated/openapi.json", sidebar: { collapsed: false } }, ]), ], - sidebar: [{ label: "Docs", items: ["index", "webhooks"] }, ...openAPISidebarGroups], + sidebar: [ + { label: "Docs", items: ["index", "webhooks", "organization-authentication"] }, + ...openAPISidebarGroups, + ], }), mermaid(), ], diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md new file mode 100644 index 000000000..eb4dd92e2 --- /dev/null +++ b/docs/src/content/docs/organization-authentication.md @@ -0,0 +1,222 @@ +--- +title: Organizations, authentication and authorization +sidebar: + label: Organizations and authentication + order: 10 +--- + +Creating organizations is permission-less. Any user can create an organization and will be the owner. +Then the owner can add members with admin role and those admins will be able to add more members with different roles. + +Better auth client and viem are the recommended libraries to use for authentication and signing using SIWE. + +## SIWE Authentication + +Example code to authenticate using SIWE, it will create the user if doesn't exist. +Note: Check viem account to use a private key instead of a mnemonic. + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const authClient = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test test"); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + //can be any statement + const statement = "i accept exa terms and conditions"; + const nonce = nonceResult?.nonce ?? ""; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: "https://localhost", + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "localhost", + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }).catch((error: unknown) => { + console.error("nonce error", error); + }); +``` + +## Creating an organization + +owner account will be the owner of the created organization + +```typescript +const chainId = optimismSepolia.id; + +const authClient = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test siwe"); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + const statement = `i accept exa terms and conditions`; + const nonce = nonceResult?.nonce ?? ""; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://localhost`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "localhost", + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const createOrganizationResult = await authClient.organization.create({ + fetchOptions: { headers }, + name: "Uphold", + slug: "uphold", + keepCurrentActiveOrganization: false, + }); + if (createOrganizationResult.data) { + console.log(`organization created id: ${createOrganizationResult.data.id}`); + } else { + console.error("Failed to create organization error:", createOrganizationResult.error); + } + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }).catch((error: unknown) => { + console.error("nonce error", error); + }); + ``` + +## Using properly the header to create a webhook + + ```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; +const baseURL = "http://localhost:3000"; +const authClient = createAuthClient({ + baseURL, + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test test"); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + const statement = `i accept exa terms and conditions`; + const nonce = nonceResult?.nonce ?? ""; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://localhost`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "localhost", + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const webhooks = await authClient.$fetch(`${baseURL}/api/webhook`, { + headers, + }); + console.log("webhooks", webhooks); + + // only if owner or admin roles for the organization + const newWebhook = await authClient.$fetch(`${baseURL}/api/webhook`, { + headers, + method: "POST", + body: { + name: "foobar", + url: "https://test.com", + }, + }); + console.log("new webhook", newWebhook); + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }) + .catch((error: unknown) => { + console.error("nonce error", error); + }); + + ``` diff --git a/server/api/index.ts b/server/api/index.ts index ec7c91328..b1af0a2cc 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -10,6 +10,7 @@ import kyc from "./kyc"; import passkey from "./passkey"; import pax from "./pax"; import ramp from "./ramp"; +import webhook from "./webhook"; import appOrigin from "../utils/appOrigin"; const api = new Hono() @@ -26,7 +27,8 @@ const api = new Hono() .route("/kyc", kyc) .route("/passkey", passkey) // eslint-disable-line @typescript-eslint/no-deprecated -- // TODO remove .route("/pax", pax) - .route("/ramp", ramp); + .route("/ramp", ramp) + .route("/webhook", webhook); export default api; export type ExaAPI = typeof api; diff --git a/server/api/kyc.ts b/server/api/kyc.ts index ae66adff2..ee8802acd 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -261,7 +261,7 @@ export default new Hono() const application = await submitApplication(payload); await database .update(credentials) - .set({ pandaId: application.id, source: "uphold" }) // TODO get source from signer + .set({ pandaId: application.id, source: "oo7rxPhUbL4e8mtWGWInMGEXOe3eHX5H" }) // TODO get source from signer cspell:ignore oo7rxPhUbL4e8mtWGWInMGEXOe3eHX5H .where(eq(credentials.id, credentialId)); return c.json({ code: "ok", legacy: "ok" }, 200); }, diff --git a/server/api/webhook.ts b/server/api/webhook.ts new file mode 100644 index 000000000..6e3f989fd --- /dev/null +++ b/server/api/webhook.ts @@ -0,0 +1,271 @@ +import { Mutex } from "async-mutex"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import { resolver, validator as vValidator } from "hono-openapi/valibot"; +import { randomBytes } from "node:crypto"; +import { literal, metadata, object, optional, parse, picklist, pipe, record, string, union } from "valibot"; + +import database, { sources } from "../database"; +import authValidator from "../middleware/auth"; +import auth from "../utils/auth"; +import validatorHook from "../utils/validatorHook"; + +const BaseWebhook = object({ + url: string(), + transaction: optional( + object({ created: optional(string()), updated: optional(string()), completed: optional(string()) }), + ), + card: optional(object({ updated: optional(string()) })), + user: optional(object({ updated: optional(string()) })), +}); + +const Webhook = object({ ...BaseWebhook.entries, secret: string() }); + +const WebhookConfig = object({ type: picklist(["uphold"]), webhooks: record(string(), Webhook) }); + +const mutexes = new Map(); +function createMutex(organizationId: string) { + const mutex = new Mutex(); + mutexes.set(organizationId, mutex); + return mutex; +} + +export default new Hono() + .get( + "/", + authValidator(), + describeRoute({ + summary: "Get webhook information", + description: `Retrieve the organization's webhook information for an authenticated user the belongs to the organization. Only owner and admin roles can read the webhook information.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook information", + content: { "application/json": { schema: resolver(record(string(), BaseWebhook), { errorMode: "ignore" }) } }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const organizations = await auth.api.listOrganizations({ + headers: c.req.raw.headers, + }); + const organizationId = organizations[0]?.id; + if (!organizationId) return c.json({ code: "no organization" }, 403); + + const { success: canRead } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId, permissions: { webhook: ["read"] } }, + }); + if (!canRead) return c.json({ code: "no permission" }, 403); + + const source = await database.query.sources.findFirst({ + where: eq(sources.id, organizationId), + }); + if (!source) return c.json({}, 200); + const config = parse( + object({ ...WebhookConfig.entries, webhooks: record(string(), BaseWebhook) }), + source.config, + ); + return c.json(config.webhooks, 200); + }, + ) + .post( + "/", + authValidator(), + describeRoute({ + summary: "Creates or updates a webhook", + description: `it creates a new webhook if it doesn't exist or updates the existing webhook if it does. Only owner and admin roles can create or update a webhook.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook created or updated", + content: { "application/json": { schema: resolver(Webhook, { errorMode: "ignore" }) } }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + vValidator( + "json", + object({ + name: string(), + url: string(), + transaction: optional( + object({ + created: optional(string()), + updated: optional(string()), + completed: optional(string()), + }), + ), + card: optional(object({ updated: optional(string()) })), + user: optional(object({ updated: optional(string()) })), + }), + validatorHook(), + ), + async (c) => { + const { name, ...payload } = c.req.valid("json"); + const organizations = await auth.api.listOrganizations({ headers: c.req.raw.headers }); + const id = organizations[0]?.id; + if (!id) return c.json({ code: "no organization" }, 403); + const { success: canCreate } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId: id, permissions: { webhook: ["create"] } }, + }); + if (!canCreate) return c.json({ code: "no permission" }, 403); + + const mutex = mutexes.get(id) ?? createMutex(id); + return mutex.runExclusive(async () => { + const source = await database.query.sources.findFirst({ + where: eq(sources.id, id), + }); + if (source) { + const config = parse(WebhookConfig, source.config); + const webhook = { ...payload, secret: config.webhooks[name]?.secret ?? randomBytes(16).toString("hex") }; + await database + .update(sources) + .set({ config: { ...config, webhooks: { ...config.webhooks, [name]: webhook } } }) + .where(eq(sources.id, id)); + return c.json(webhook, 200); + } else { + const webhook = { ...payload, secret: randomBytes(16).toString("hex") }; + await database.insert(sources).values({ id, config: { type: "uphold", webhooks: { [name]: webhook } } }); + return c.json(webhook, 200); + } + }); + }, + ) + .delete( + "/", + authValidator(), + vValidator("json", object({ name: string() }), validatorHook()), + describeRoute({ + summary: "Deletes a webhook", + description: `it deletes the webhook with the given name. Only owner and admin roles can delete a webhook.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook deleted", + content: { + "application/json": { schema: resolver(object({ code: literal("ok") }), { errorMode: "ignore" }) }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const { name } = c.req.valid("json"); + const organizations = await auth.api.listOrganizations({ headers: c.req.raw.headers }); + const id = organizations[0]?.id; + if (!id) return c.json({ code: "no organization" }, 403); + + const { success: canDelete } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId: id, permissions: { webhook: ["delete"] } }, + }); + if (!canDelete) return c.json({ code: "no permission" }, 403); + + const mutex = mutexes.get(id) ?? createMutex(id); + return mutex.runExclusive(async () => { + const source = await database.query.sources.findFirst({ + where: eq(sources.id, id), + }); + if (source) { + const config = parse(WebhookConfig, source.config); + const { [name]: _, ...remainingWebhooks } = config.webhooks; + await database + .update(sources) + .set({ config: { ...config, webhooks: remainingWebhooks } }) + .where(eq(sources.id, id)); + } + return c.json({ code: "ok" }, 200); + }); + }, + ); diff --git a/server/database/index.ts b/server/database/index.ts index d288cb2f3..32f57021e 100644 --- a/server/database/index.ts +++ b/server/database/index.ts @@ -1,3 +1,4 @@ +import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzle } from "drizzle-orm/node-postgres"; import { env } from "node:process"; @@ -5,6 +6,22 @@ import * as schema from "./schema"; if (!env.POSTGRES_URL) throw new Error("missing postgres url"); -export default drizzle(env.POSTGRES_URL, { schema }); +const database = drizzle(env.POSTGRES_URL, { schema }); + +export default database; export * from "./schema"; + +export const authAdapter = drizzleAdapter(database, { + provider: "pg", + schema: { + user: schema.users, + session: schema.sessions, + account: schema.authenticators, + verification: schema.verifications, + walletAddress: schema.walletAddresses, + organization: schema.organizations, + member: schema.members, + invitation: schema.invitations, + }, +}); diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index e876f1052..3caa2f045 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1258,9 +1258,6 @@ async function publish(payload: v.InferOutput) { const config = v.parse(webhookConfig, user.source.config); await Promise.allSettled( Object.values(config.webhooks).map(async (webhook) => { - const secret = config.secrets[webhook.secretId]?.key; - if (!secret) throw new Error("secret not found"); - switch (payload.resource) { case "user": return sendWebhook( @@ -1270,7 +1267,7 @@ async function publish(payload: v.InferOutput) { body: { ...payload.body, credentialId: user.id }, }), webhook.card?.[payload.action] ?? webhook.url, - secret, + webhook.secret, ); case "card": // falls through @@ -1281,7 +1278,7 @@ async function publish(payload: v.InferOutput) { timestamp, }), webhook.transaction?.[payload.action] ?? webhook.url, - secret, + webhook.secret, ); } }), @@ -1405,12 +1402,11 @@ const Webhook = v.variant("resource", [ const webhookConfig = v.object({ type: v.picklist(["uphold"]), - secrets: v.record(v.string(), v.object({ key: v.string(), type: v.picklist(["HMAC-SHA256"]) })), webhooks: v.record( v.string(), v.object({ url: v.string(), - secretId: v.string(), + secret: v.string(), transaction: v.optional( v.object({ created: v.optional(v.string()), diff --git a/server/index.ts b/server/index.ts index 4dc9c5aec..88a3aabe0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -18,6 +18,7 @@ import panda from "./hooks/panda"; import persona from "./hooks/persona"; import androidFingerprints from "./utils/android/fingerprints"; import appOrigin from "./utils/appOrigin"; +import auth from "./utils/auth"; import { closeAndFlush as closeSegment } from "./utils/segment"; import type { UnofficialStatusCode } from "hono/utils/http-status"; @@ -306,6 +307,8 @@ app.onError((error, c) => { return c.json({ code: "unexpected error", legacy: "unexpected error" }, 555 as UnofficialStatusCode); }); +app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw)); + export default app; const server = serve(app); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 45c9b5a33..aa423f5cd 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,6 +1,7 @@ import { getSignedCookie } from "hono/cookie"; import { createMiddleware } from "hono/factory"; +import betterAuth from "../utils/auth"; import authSecret from "../utils/authSecret"; import type { BlankInput, Env, Input } from "hono/types"; @@ -8,7 +9,14 @@ import type { BlankInput, Env, Input } from "hono/types"; export default function auth() { return createMiddleware(async (c, next) => { const credentialId = await getSignedCookie(c, authSecret, "credential_id"); - if (!credentialId) return c.json({ code: "unauthorized", legacy: "unauthorized" }, 401); + if (!credentialId) { + const session = await betterAuth.api.getSession({ headers: c.req.raw.headers }); + if (session) { + await next(); + return; + } + return c.json({ code: "unauthorized", legacy: "unauthorized" }, 401); + } c.req.addValidatedData("cookie", { credentialId }); await next(); }); diff --git a/server/script/openapi.ts b/server/script/openapi.ts index 553dff196..ef9d152b3 100644 --- a/server/script/openapi.ts +++ b/server/script/openapi.ts @@ -1,12 +1,12 @@ import { generateSpecs } from "hono-openapi"; import { writeFile } from "node:fs/promises"; -import { padHex } from "viem"; +import { padHex, zeroHash } from "viem"; import { version } from "../package.json"; process.env.ALCHEMY_ACTIVITY_ID = "activity"; process.env.ALCHEMY_WEBHOOKS_KEY = "webhooks"; -process.env.AUTH_SECRET = "auth"; +process.env.AUTH_SECRET = zeroHash; process.env.BRIDGE_API_KEY = "bridge"; process.env.BRIDGE_API_URL = "https://bridge.test"; process.env.EXPO_PUBLIC_ALCHEMY_API_KEY = " "; @@ -49,6 +49,7 @@ import("../api") in: "cookie", name: "credential_id", }, + siweAuth: { type: "apiKey", in: "cookie", name: "__Secure-better-auth.session_token" }, }, }, }, diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 5da742691..b76f28440 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -1053,7 +1053,7 @@ describe("authenticated", () => { beforeAll(async () => { await database.insert(sources).values([ { - id: "uphold", + id: "oo7rxPhUbL4e8mtWGWInMGEXOe3eHX5H", // cspell:ignore oo7rxPhUbL4e8mtWGWInMGEXOe3eHX5H config: { type: "uphold", secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, diff --git a/server/test/api/webhook.test.ts b/server/test/api/webhook.test.ts new file mode 100644 index 000000000..dd72e5cf9 --- /dev/null +++ b/server/test/api/webhook.test.ts @@ -0,0 +1,209 @@ +import "../mocks/sentry"; + +import { eq } from "drizzle-orm"; +import { testClient } from "hono/testing"; +import { mnemonicToAccount } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import chain from "@exactly/common/generated/chain"; + +import app from "../../api/webhook"; +import database, { sources } from "../../database"; +import auth from "../../utils/auth"; + +const appClient = testClient(app); + +const owner = mnemonicToAccount("test test test test test test test test test test test junk"); +const integratorAccount = mnemonicToAccount("test test test test test test test test test test test integrator"); + +describe("webhook", () => { + const integratorHeaders = new Headers(); + + describe("authenticated", () => { + beforeAll(async () => { + const adminNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + + const statement = "I accept Exa terms and conditions"; + const ownerMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: adminNonceResult.nonce, + uri: `https://localhost`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + + const adminResponse = await auth.api.verifySiweMessage({ + body: { + message: ownerMessage, + signature: await owner.signMessage({ message: ownerMessage }), + walletAddress: owner.address, + chainId: chain.id, + }, + request: new Request("https://localhost"), + asResponse: true, + }); + const ownerHeaders = new Headers(); + ownerHeaders.set("cookie", `${adminResponse.headers.get("set-cookie")}`); + + const integratorNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: integratorAccount.address, chainId: chain.id }, + }); + const integratorMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: integratorNonceResult.nonce, + uri: `https://localhost`, + address: integratorAccount.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + const integratorResponse = await auth.api.verifySiweMessage({ + body: { + message: integratorMessage, + signature: await integratorAccount.signMessage({ message: integratorMessage }), + walletAddress: integratorAccount.address, + chainId: chain.id, + email: "integrator@external.com", + }, + request: new Request("https://localhost"), + asResponse: true, + }); + integratorHeaders.set("cookie", `${integratorResponse.headers.get("set-cookie")}`); + const integrator = await auth.api.getSession({ headers: integratorHeaders }); + if (!integrator) throw new Error("integrator not found"); + const externalOrganization = await auth.api.createOrganization({ + headers: ownerHeaders, + body: { name: "External Organization", slug: "external-organization" }, + }); + + const integratorInvitation = await auth.api.createInvitation({ + headers: ownerHeaders, + body: { email: integrator.user.email, role: "admin", organizationId: externalOrganization?.id }, + }); + await auth.api.acceptInvitation({ headers: integratorHeaders, body: { invitationId: integratorInvitation.id } }); + }); + + afterEach(async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + await database.delete(sources).where(eq(sources.id, id)); + }); + + it("creates and gets a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + const cookie = integratorHeaders.get("cookie") ?? ""; + + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie } }, + ); + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + const getWebhook = await appClient.index.$get({}, { headers: { cookie } }); + + expect(getWebhook.status).toBe(200); + expect(response.status).toBe(200); + expect(source?.config).toStrictEqual({ + type: "uphold", + webhooks: { + test: { url: "https://test.com", secret: expect.any(String) }, // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }, + }); + + await expect(getWebhook.json()).resolves.toStrictEqual({ + test: { + url: "https://test.com", + }, + }); + }); + + it("updates a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + + const create = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const update = await appClient.index.$post( + { + json: { + name: "test", + url: "https://test.updated.com", + transaction: { created: "https://test.updated.com/created" }, + }, + }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const createAnother = await appClient.index.$post( + { + json: { + name: "another", + url: "https://another.updated.com", + transaction: { created: "https://another.updated.com/created" }, + }, + }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + expect(source?.config).toStrictEqual({ + type: "uphold", + webhooks: { + test: { + url: "https://test.updated.com", + secret: expect.any(String), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + transaction: { created: "https://test.updated.com/created" }, + }, + another: { + url: "https://another.updated.com", + secret: expect.any(String), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + transaction: { created: "https://another.updated.com/created" }, + }, + }, + }); + + expect(create.status).toBe(200); + expect(update.status).toBe(200); + expect(createAnother.status).toBe(200); + }); + + it("deletes a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + const create = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const remove = await appClient.index.$delete( + { json: { name: "test" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + expect(source?.config).toStrictEqual({ type: "uphold", webhooks: {} }); + expect(create.status).toBe(200); + expect(remove.status).toBe(200); + }); + + it("returns 200 when webhook is not found", async () => { + const getWebhook = await appClient.index.$get({}, { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }); + expect(getWebhook.status).toBe(200); + await expect(getWebhook.json()).resolves.toStrictEqual({}); + }); + }); +}); diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 425f38d3d..f6f634f51 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -9,7 +9,7 @@ import "../mocks/sentry"; import { captureException, setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { createHmac } from "node:crypto"; +import { createHmac, randomBytes } from "node:crypto"; import { object, parse, string } from "valibot"; import { BaseError, @@ -2082,6 +2082,7 @@ describe("concurrency", () => { describe("webhooks", () => { let webhookOwner: WalletClient, typeof chain, ReturnType>; let webhookAccount: Address; + const secret = randomBytes(16).toString("hex"); beforeAll(async () => { webhookOwner = createWalletClient({ @@ -2099,8 +2100,7 @@ describe("webhooks", () => { id: "test", config: { type: "uphold", - secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, - webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + webhooks: { sandbox: { url: "https://exa.test", secret } }, }, }, ]), @@ -2190,7 +2190,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); it("forwards transaction updated", async () => { @@ -2218,7 +2218,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); it("forwards transaction completed", async () => { @@ -2246,7 +2246,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); it("forwards card updated", async () => { @@ -2274,7 +2274,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); it("forwards user updated", async () => { @@ -2301,7 +2301,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); }); diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 000000000..7da9b5f59 --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,62 @@ +import { captureException } from "@sentry/core"; +import { betterAuth } from "better-auth"; +import { organization, siwe } from "better-auth/plugins"; +import { createAccessControl } from "better-auth/plugins/access"; +import { adminAc, defaultStatements, memberAc, ownerAc } from "better-auth/plugins/organization/access"; +import { verifyMessage } from "viem"; +import { generateSiweNonce } from "viem/siwe"; + +import domain from "@exactly/common/domain"; + +import authSecret from "./authSecret"; +import { authAdapter } from "../database/index"; +const ac = createAccessControl({ + ...defaultStatements, + webhook: ["create", "delete", "read"], +}); + +export default betterAuth({ + database: authAdapter, + baseURL: `https://${domain}`, + secret: authSecret, + plugins: [ + siwe({ + domain, + emailDomainName: domain === "localhost" ? "localhost.com" : domain, + anonymous: true, + getNonce: async () => { + return await Promise.resolve(generateSiweNonce()); + }, + verifyMessage: async ({ message, signature, address }) => { + try { + const isValid = await verifyMessage({ + address: address as `0x${string}`, + message, + signature: signature as `0x${string}`, + }); + return isValid; + } catch (error) { + captureException(error, { level: "error" }); + return false; + } + }, + }), + organization({ + ac, + roles: { + admin: ac.newRole({ + webhook: ["create", "delete", "read"], + ...adminAc.statements, + }), + owner: ac.newRole({ + webhook: ["create", "delete", "read"], + ...ownerAc.statements, + }), + member: ac.newRole({ + ...memberAc.statements, + }), + }, + allowUserToCreateOrganization: () => true, + }), + ], +}); From d602f0e2c5b90bcfcf69dc102322929054177880 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Mon, 9 Feb 2026 16:20:20 -0300 Subject: [PATCH 11/84] =?UTF-8?q?=E2=9E=95=20server:=20install=20canonical?= =?UTF-8?q?ize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 9 +++++++++ server/package.json | 1 + 2 files changed, 10 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aa57edbf..28fe6de67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -768,6 +768,9 @@ importers: bullmq: specifier: ^5.66.5 version: 5.66.5 + canonicalize: + specifier: ^2.1.0 + version: 2.1.0 debug: specifier: ^4.4.3 version: 4.4.3 @@ -7052,6 +7055,10 @@ packages: caniuse-lite@1.0.30001765: resolution: {integrity: sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==} + canonicalize@2.1.0: + resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -21686,6 +21693,8 @@ snapshots: caniuse-lite@1.0.30001765: {} + canonicalize@2.1.0: {} + ccount@2.0.1: {} chai@6.2.2: {} diff --git a/server/package.json b/server/package.json index 714f752f8..f68c1afa3 100644 --- a/server/package.json +++ b/server/package.json @@ -47,6 +47,7 @@ "async-mutex": "^0.5.0", "bullmq": "^5.66.5", "better-auth": "^1.4.18", + "canonicalize": "^2.1.0", "debug": "^4.4.3", "drizzle-orm": "^0.45.1", "graphql": "^16.12.0", From 80b3da44133993890088cc7260b893839d3c6ef5 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 23 Sep 2025 17:01:48 -0300 Subject: [PATCH 12/84] =?UTF-8?q?=E2=9C=A8=20server:=20add=20encrypted=20k?= =?UTF-8?q?yc=20submission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cspell.json | 1 + .../docs/organization-authentication.md | 118 ++++ server/api/kyc.ts | 234 +++++++- server/test/api/kyc.test.ts | 563 ++++++++++++------ server/utils/auth.ts | 3 + server/utils/panda.ts | 89 ++- 6 files changed, 807 insertions(+), 201 deletions(-) diff --git a/cspell.json b/cspell.json index f2a919e27..37e13eaa4 100644 --- a/cspell.json +++ b/cspell.json @@ -38,6 +38,7 @@ "checkpointing", "checksummed", "clippy", + "ciphertext", "codegen", "codepoint", "colocating", diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index eb4dd92e2..2b7d2afa4 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -220,3 +220,121 @@ authClient.siwe }); ``` + +## How to create the encrypted KYC payload with SIWE statement + + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import crypto from "node:crypto"; +import { getAddress, sha256 } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const authClient = createAuthClient({ + baseURL: "https://sandbox.exactly.app", + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test siwe"); + +function encrypt(payload: string) { + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv); + + const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + + const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY-----`; + + const key = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + aesKey, + ); + + return { + key: key.toString("base64"), + iv: iv.toString("base64"), + ciphertext: ciphertext.toString("base64"), + tag: tag.toString("base64"), + hash: sha256(ciphertext), + }; +} + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + if (!nonceResult) throw new Error("No nonce"); + const data = { + email: "john.doe@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "123456789", + birthDate: "1990-05-15", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main Street", + line2: "Apt 4B", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "192.168.1.100", + occupation: "11-1011", + annualSalary: "75000", + accountPurpose: "Personal Banking", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true, + }; + const encryptedPayload = encrypt(JSON.stringify(data)); + const exaAccountUserAddress = "0xa7d5e73027844145A538F4bfD7b8d9b41d8B89d3"; + const statement = `I apply for KYC approval on behalf of address ${getAddress(exaAccountUserAddress)} with payload hash ${encryptedPayload.hash}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: nonceResult.nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId, + }; + const { hash, ...payload } = encryptedPayload; + console.log("application payload", { ...payload, verify }); + }) + .catch((error: unknown) => { + console.error("nonce error", error); + }); + ``` diff --git a/server/api/kyc.ts b/server/api/kyc.ts index ee8802acd..fc8f35b7e 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -1,11 +1,13 @@ import { captureException, setContext, setUser, startSpan } from "@sentry/node"; +import canonicalize from "canonicalize"; import createDebug from "debug"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute } from "hono-openapi"; +import * as honoOpenapi from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; import { array, literal, metadata, object, optional, parse, picklist, pipe, string, union } from "valibot"; -import { getAddress } from "viem"; +import { getAddress, sha256 } from "viem"; +import { parseSiweMessage } from "viem/siwe"; import accountInit from "@exactly/common/accountInit"; import { @@ -17,11 +19,13 @@ import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; import auth from "../middleware/auth"; +import betterAuth from "../utils/auth"; import decodePublicKey from "../utils/decodePublicKey"; import { SubmitApplicationRequest as Application, UpdateApplicationRequest as ApplicationUpdate, getApplicationStatus, + KycError, submitApplication, updateApplication, } from "../utils/panda"; @@ -54,6 +58,13 @@ const BadRequestCodes = { BAD_REQUEST: "bad request", } as const; +function buildBaseResponse(example = "string") { + return object({ + code: pipe(string(), metadata({ examples: [example] })), + legacy: pipe(string(), metadata({ examples: [example] })), + }); +} + export default new Hono() .get( "/", @@ -209,16 +220,107 @@ export default new Hono() .post( "/application", auth(), - describeRoute({ + honoOpenapi.describeRoute({ summary: "Submit KYC application", - description: "Submit information for KYC application", + description: ` +Submit information for KYC application. + +**Encrypted kyc payload** + +When the header has encrypted=true, the payload should be encrypted. + +The steps to encrypt are: + +1. Generate AES Key: Create a random 256-bit AES key +2. Encrypt Payload: Use AES-256-GCM to encrypt your KYC JSON data +3. Encrypt AES Key: Use Rain-provided RSA public key with OAEP padding +4. Encode Components: Base64-encode all encrypted components +5. Set Header: Include encrypted: "true" header in your request +6. Submit Request + +KYC Encryption Public Key for sandbox is: + +\`\`\` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY----- +\`\`\` + +KYC Encryption Public Key for production needs to be provided. + +A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) + +**Payload structure before encryption** + +1. Personal information (name, date of birth, address) +2. Identity verification documents +3. Compliance information (occupation, income, etc.) +4. Terms of service acceptance + +Here's the markdown table with object notation for nested fields: + +| fieldName | type | example | notes | +|-----------|------|---------|-------| +| email | string | user@domain.com | | +| lastName | string | Doe | | +| firstName | string | John | | +| nationalId | string | 123456789 | | +| birthDate | string | 1970-01-01 | | +| countryOfIssue | string | US | | +| phoneCountryCode | string | 1 | | +| phoneNumber | string | 5551234567 | | +| address.line1 | string | 123 Main Street | | +| address.line2 | string | Apt 4B | | +| address.city | string | New York | | +| address.region | string | NY | | +| address.postalCode | string | 10001 | | +| address.countryCode | string | US | | +| ipAddress | string | 192.168.1.100 | | +| occupation | string | 11-1011 | Ask for the mandatory occupation codes | +| annualSalary | string | 75000 | | +| accountPurpose | string | Personal Banking | | +| expectedMonthlyVolume | string | 5000 | | +| isTermsOfServiceAccepted | boolean | true | | + +**Authentication and organization verification** + +The exa account needs to be authenticated but also a member of the organization that submit the KYC application needs to probe that +belong to the organization and needs to have *kyc* permission, every owner and admin of an organization has this permission. + +To probe the member of the organization needs to generate a SIWE message with the following statement and viem library is recommended: + +"I apply for KYC approval on behalf of [lowercase exa account address]" + +The siwe message will be: + +| fieldName | type | example | notes | +|-----------|------|---------|-------| +| verify.message | string | SIWE message that includes the statement | | +| verify.signature | string | signature of the message | | +| verify.walletAddress | string | address of the member of the organization that signed the message | | +| verify.chainId | number | 11155420 | | + +A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) + +Note that the member of the organization must be created, the organization must exist and the member must be added as admin by another admin or owner. + +Working example about how to login is [here](../../../organization-authentication/#siwe-authentication) + +The admin should add a member using [addMember method](https://www.better-auth.com/docs/plugins/organization#add-member). +`, tags: ["KYC"], responses: { 200: { description: "KYC application submitted successfully", content: { "application/json": { - schema: resolver(buildBaseResponse("ok"), { errorMode: "ignore" }), + schema: resolver(object({ id: string(), status: string() }), { errorMode: "ignore" }), }, }, }, @@ -228,12 +330,48 @@ export default new Hono() "application/json": { schema: resolver( union([ - buildBaseResponse(BadRequestCodes.ALREADY_STARTED), + object({ code: literal("invalid encryption"), message: string() }), object({ ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, message: optional(array(string())), }), ]), + { + errorMode: "ignore", + }, + ), + }, + }, + }, + 401: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: literal(BadRequestCodes.ALREADY_STARTED) }), + object({ + code: literal("invalid payload"), + message: string(), + }), + object({ + code: string(), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver( + object({ + code: literal("no permission"), + message: optional(string()), + }), { errorMode: "ignore" }, ), }, @@ -243,33 +381,86 @@ export default new Hono() validateResponse: true, }), vValidator("json", Application, validatorHook({ debug })), + vValidator("header", optional(object({ encrypted: optional(string()) })), validatorHook({ debug })), async (c) => { - const { credentialId } = c.req.valid("cookie"); const payload = c.req.valid("json"); + const verifyResponse = await betterAuth.api.verifySiweMessage({ + body: payload.verify, + request: c.req.raw, + asResponse: true, + }); + if (!verifyResponse.ok) { + const errorBody = parse(object({ code: string(), message: string() }), await verifyResponse.json()); + return c.json({ code: "no permission", message: errorBody.message }, 403); + } + const headers = new Headers(); + headers.set("cookie", verifyResponse.headers.get("set-cookie") ?? ""); + const organizations = await betterAuth.api.listOrganizations({ headers }); + const source = organizations[0]?.id; + if (!source) return c.json({ code: "no organization" }, 403); + + const { success: canCreate } = await betterAuth.api.hasPermission({ + headers, + body: { + organizationId: source, + permissions: { + kyc: ["create"], + }, + }, + }); + if (!canCreate) return c.json({ code: "no permission" }, 403); + + const { credentialId } = c.req.valid("cookie"); const credential = await database.query.credentials.findFirst({ columns: { id: true, account: true, pandaId: true }, where: eq(credentials.id, credentialId), }); - if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); + if (!credential) return c.json({ code: "no credential" }, 500); setUser({ id: parse(Address, credential.account) }); setContext("exa", { credential }); - if (credential.pandaId) { - return c.json({ code: BadRequestCodes.ALREADY_STARTED, legacy: BadRequestCodes.ALREADY_STARTED }, 400); + const siweMessage = parseSiweMessage(payload.verify.message); + const { verify, ...body } = payload; + const hash = + "ciphertext" in body + ? sha256(Buffer.from(body.ciphertext, "base64")) + : sha256(Buffer.from(JSON.stringify(canonicalize(body)), "utf8")); + if ( + siweMessage.statement !== + `I apply for KYC approval on behalf of address ${parse(Address, credential.account)} with payload hash ${hash}` + ) { + return c.json({ code: "no permission", message: "invalid statement" }, 403); } - const application = await submitApplication(payload); - await database - .update(credentials) - .set({ pandaId: application.id, source: "oo7rxPhUbL4e8mtWGWInMGEXOe3eHX5H" }) // TODO get source from signer cspell:ignore oo7rxPhUbL4e8mtWGWInMGEXOe3eHX5H - .where(eq(credentials.id, credentialId)); - return c.json({ code: "ok", legacy: "ok" }, 200); + if (credential.pandaId) { + return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 401); + } + try { + const application = await submitApplication(payload, c.req.header("encrypted") === "true"); + await database + .update(credentials) + .set({ pandaId: application.id, source }) + .where(eq(credentials.id, credentialId)); + return c.json({ id: application.id, status: application.applicationStatus }, 200); + } catch (error) { + if (error instanceof KycError) { + switch (error.statusCode) { + case 400: + return c.json({ code: "invalid encryption", message: error.message }, 400); + case 401: + return c.json({ code: "invalid payload", message: error.message }, 401); + default: + return c.json({ code: error.message }, 401); + } + } + throw error; + } }, ) .patch( "/application", auth(), - describeRoute({ + honoOpenapi.describeRoute({ summary: "Update KYC application", description: "Update the KYC application", tags: ["KYC"], @@ -323,7 +514,7 @@ export default new Hono() .get( "/application", auth(), - describeRoute({ + honoOpenapi.describeRoute({ summary: "Get KYC application status", description: "Get the status of the KYC application", tags: ["KYC"], @@ -405,10 +596,3 @@ async function generateInquiryTokens(inquiryId: string): Promise<{ inquiryId: st const { meta: sessionTokenMeta } = await resumeInquiry(inquiryId); return { inquiryId, sessionToken: sessionTokenMeta["session-token"] }; } - -function buildBaseResponse(example = "string") { - return object({ - code: pipe(string(), metadata({ examples: [example] })), - legacy: pipe(string(), metadata({ examples: [example] })), - }); -} diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index b76f28440..42c45fbc8 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -3,16 +3,21 @@ import "../mocks/deployments"; import "../mocks/sentry"; import { captureException } from "@sentry/node"; +import canonicalize from "canonicalize"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { padHex, zeroAddress, zeroHash } from "viem"; -import { privateKeyToAddress } from "viem/accounts"; +import crypto from "node:crypto"; +import { getAddress, padHex, sha256, zeroAddress, zeroHash } from "viem"; +import { mnemonicToAccount, privateKeyToAddress } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; +import chain from "@exactly/common/generated/chain"; import app from "../../api/kyc"; import database, { credentials, sources } from "../../database"; +import auth from "../../utils/auth"; import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; import { scopeValidationErrors } from "../../utils/persona"; @@ -1011,200 +1016,420 @@ describe("authenticated", () => { }); describe("application", () => { - describe("status", () => { - it("returns status", async () => { - await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); - const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ - id: "pandaId", - applicationStatus: "approved", - applicationReason: "", - }); - const response = await appClient.application.$get( - { query: {} }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + describe("with organization", () => { + const owner = mnemonicToAccount("test test test test test test test test test test test kyc"); + const ownerHeaders: Headers = new Headers(); + let organizationId: string; - await expect(response.json()).resolves.toStrictEqual({ - code: "ok", - legacy: "ok", - status: "approved", - reason: "", + beforeAll(async () => { + const adminNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, }); - expect(getApplicationStatus).toHaveBeenCalledWith("pandaId"); - expect(response.status).toBe(200); - }); - - it("returns not started when no panda id", async () => { - await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); - const response = await appClient.application.$get( - { query: {} }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "not started", - legacy: "not started", + const statement = "I accept Exa terms and conditions"; + const ownerMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: adminNonceResult.nonce, + uri: `https://localhost`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", }); - }); - }); - describe("submit", () => { - beforeAll(async () => { - await database.insert(sources).values([ - { - id: "oo7rxPhUbL4e8mtWGWInMGEXOe3eHX5H", // cspell:ignore oo7rxPhUbL4e8mtWGWInMGEXOe3eHX5H - config: { - type: "uphold", - secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, - webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, - }, + const adminResponse = await auth.api.verifySiweMessage({ + body: { + message: ownerMessage, + signature: await owner.signMessage({ message: ownerMessage }), + walletAddress: owner.address, + chainId: chain.id, }, - ]); + request: new Request("https://localhost"), + asResponse: true, + }); + ownerHeaders.set("cookie", `${adminResponse.headers.get("set-cookie")}`); + + const externalOrganization = await auth.api.createOrganization({ + headers: ownerHeaders, + body: { + name: "Organization", + slug: "organization", + keepCurrentActiveOrganization: false, + }, + }); + organizationId = externalOrganization?.id ?? ""; }); - it("returns ok when payload is valid and kyc is not started", async () => { - await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - arrayBuffer: () => - Promise.resolve( - new TextEncoder().encode( - JSON.stringify({ - id: "pandaId", - applicationStatus: "approved", - }), - ).buffer, - ), - } as Response); - - const response = await appClient.application.$post( - { json: applicationPayload }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); - - const updatedCredential = await database.query.credentials.findFirst({ - where: eq(credentials.id, account), + describe("status", () => { + it("returns status", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ + id: "pandaId", + applicationStatus: "approved", + applicationReason: "", + }); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ + code: "ok", + legacy: "ok", + status: "approved", + reason: "", + }); + expect(getApplicationStatus).toHaveBeenCalledWith("pandaId"); + expect(response.status).toBe(200); }); - const calls = mockFetch.mock.calls; - const body = calls[0]?.[1]?.body; - expect(response.status).toBe(200); - expect(updatedCredential?.pandaId).toBe("pandaId"); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/issuing/applications/user`), - expect.objectContaining({ - method: "POST", - }), - ); - expect(JSON.parse(body as string)).toStrictEqual(applicationPayload); - await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + it("returns not started when no panda id", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); }); - it("returns 400 when kyc is already started", async () => { - const submitApplication = vi.spyOn(panda, "submitApplication"); - - const response = await appClient.application.$post( - { json: applicationPayload }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + describe("submit", () => { + beforeAll(async () => { + await database.insert(sources).values([ + { + id: organizationId, + config: { + type: "uphold", + secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, + webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + }, + }, + ]); + }); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "already started", - legacy: "already started", + it("returns ok when payload is valid and kyc is not started", async () => { + const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; + const { nonce } = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user`), + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ ...applicationPayload, verify }); + await expect(response.json()).resolves.toStrictEqual({ id: "pandaId", status: "approved" }); }); - expect(submitApplication).not.toHaveBeenCalled(); - }); - it("returns 400 when payload is invalid", async () => { - const response = await appClient.application.$post( - { json: {} as unknown as v.InferOutput }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + it("returns 401 when kyc is already started", async () => { + const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; + const { nonce } = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - code: "bad request", - legacy: "bad request", - message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment - }); - }); + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; - it("returns 400 if terms of service are not accepted", async () => { - const response = await appClient.application.$post( - { json: { ...applicationPayload, isTermsOfServiceAccepted: false } }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + const submitApplication = vi.spyOn(panda, "submitApplication"); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "bad request", - legacy: "bad request", - message: ["isTermsOfServiceAccepted Invalid type: Expected true but received false"], + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ + code: "already started", + }); + expect(submitApplication).not.toHaveBeenCalled(); }); - }); - }); - describe("update", () => { - it("returns ok when kyc is started", async () => { - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(new TextEncoder().encode("{}").buffer), - } as Response); - - const response = await appClient.application.$patch( - { json: { firstName: "john-updated" } }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$post( + { json: {} as unknown as v.InferOutput }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); - const calls = mockFetch.mock.calls; - const body = calls[0]?.[1]?.body; + it("returns 400 if terms of service are not accepted", async () => { + const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; + const { nonce } = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify, isTermsOfServiceAccepted: false } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + }); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(`/issuing/applications/user/pandaId`), - expect.objectContaining({ - method: "PATCH", - }), - ); - expect(JSON.parse(body as string)).toStrictEqual({ firstName: "john-updated" }); + describe("with encrypted payload", () => { + const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY-----`; + + function encrypt(payload: string) { + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv); + const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + const key = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + aesKey, + ); + + return { key, iv, ciphertext, tag }; + } + + it("returns ok when payload is valid", async () => { + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const { nonce } = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession", encrypted: "true" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user`), + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify, + }); + await expect(response.json()).resolves.toStrictEqual({ id: "pandaId", status: "approved" }); + }); + }); }); - it("returns 400 when kyc is not started", async () => { - await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); - const response = await appClient.application.$patch( - { json: { firstName: "john-updated" } }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "not started", - legacy: "not started", + describe("update", () => { + it("returns ok when kyc is started", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode("{}").buffer), + } as Response); + + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user/pandaId`), + expect.objectContaining({ + method: "PATCH", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ firstName: "john-updated" }); }); - }); - it("returns 400 when payload is invalid", async () => { - const response = await appClient.application.$patch( - { - json: { - address: { - line1: "123 main street", - }, - } as unknown as v.InferOutput, - }, - { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, - ); + it("returns 400 when kyc is not started", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); - expect(response.status).toBe(400); - await expect(response.json()).resolves.toStrictEqual({ - code: "bad request", - legacy: "bad request", - message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$patch( + { + json: { + address: { + line1: "123 main street", + }, + } as unknown as v.InferOutput, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); }); }); }); diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 7da9b5f59..bcd7bed25 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -13,6 +13,7 @@ import { authAdapter } from "../database/index"; const ac = createAccessControl({ ...defaultStatements, webhook: ["create", "delete", "read"], + kyc: ["create", "delete", "read"], }); export default betterAuth({ @@ -46,10 +47,12 @@ export default betterAuth({ roles: { admin: ac.newRole({ webhook: ["create", "delete", "read"], + kyc: ["create"], ...adminAc.statements, }), owner: ac.newRole({ webhook: ["create", "delete", "read"], + kyc: ["create"], ...ownerAc.statements, }), member: ac.newRole({ diff --git a/server/utils/panda.ts b/server/utils/panda.ts index 6bd3d7952..117419a5d 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -29,7 +29,7 @@ import { type BaseSchema, type InferInput, } from "valibot"; -import { BaseError, ContractFunctionZeroDataError } from "viem"; +import { BaseError, ContractFunctionZeroDataError, type MaybePromise } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { base, optimism } from "viem/chains"; @@ -174,6 +174,7 @@ async function request>( body?: unknown, method: "GET" | "PATCH" | "POST" | "PUT" = body === undefined ? "GET" : "POST", timeout = 10_000, + onError?: (response: Response) => MaybePromise, ) { const response = await fetch(`${baseURL}${url}`, { method, @@ -375,16 +376,67 @@ export function getMutex(address: Address) { return mutexes.get(address); } -export async function submitApplication(payload: InferInput) { - return request(ApplicationResponse, "/issuing/applications/user", {}, payload, "POST"); +export async function submitApplication(payload: InferInput, encrypted = false) { + return request( + ApplicationResponse, + "/issuing/applications/user", + { ...(encrypted && { encrypted: "true" }) }, + payload, + "POST", + 10_000, + async (response) => { + const text = await response.text(); + try { + const error = parse(object({ message: string() }), JSON.parse(text)); + throw new KycError(error.message, response.status); + } catch (error) { + if (error instanceof KycError) throw error; + throw new Error(`${response.status} ${text}`); + } + }, + ); } export async function getApplicationStatus(applicationId: string) { - return request(ApplicationStatusResponse, `/issuing/applications/user/${applicationId}`, {}, undefined, "GET"); + return request( + ApplicationStatusResponse, + `/issuing/applications/user/${applicationId}`, + {}, + undefined, + "GET", + 10_000, + async (response) => { + const text = await response.text(); + try { + const error = parse(object({ message: string() }), JSON.parse(text)); + throw new KycError(error.message, response.status); + } catch (error) { + if (error instanceof KycError) throw error; + throw new Error(`${response.status} ${text}`); + } + }, + ); } export async function updateApplication(applicationId: string, payload: InferInput) { - return request(object({}), `/issuing/applications/user/${applicationId}`, {}, payload, "PATCH"); + return request( + object({}), + `/issuing/applications/user/${applicationId}`, + {}, + payload, + "PATCH", + 10_000, + async (response) => { + const text = await response.text(); + try { + const error = parse(object({ message: string() }), JSON.parse(text)); + throw new KycError(error.message, response.status); + } catch (error) { + if (error instanceof KycError) throw error; + throw new Error(`${response.status} ${text}`); + } + }, + ); } const AddressSchema = object({ @@ -397,7 +449,7 @@ const AddressSchema = object({ countryCode: pipe(string(), length(2), regex(/^[A-Z]{2}$/i)), }); -export const SubmitApplicationRequest = object({ +export const Application = object({ email: pipe( string(), email("Invalid email address"), @@ -449,10 +501,22 @@ export const SubmitApplicationRequest = object({ literal(true), metadata({ description: "Whether the user has accepted the terms of service" }), ), + verify: object({ message: string(), signature: string(), walletAddress: string(), chainId: number() }), }); +export const SubmitApplicationRequest = union([ + Application, + object({ + key: string(), + iv: string(), + ciphertext: string(), + tag: string(), + verify: object({ message: string(), signature: string(), walletAddress: string(), chainId: number() }), + }), +]); + export const UpdateApplicationRequest = object({ - ...partial(omit(SubmitApplicationRequest, ["email", "phoneCountryCode", "phoneNumber", "address"])).entries, + ...partial(omit(Application, ["email", "phoneCountryCode", "phoneNumber", "address"])).entries, address: optional(AddressSchema), }); @@ -478,4 +542,15 @@ const ApplicationStatusResponse = object({ applicationStatus: picklist(kycStatus), applicationReason: optional(string()), }); + +export class KycError extends Error { + constructor( + message: string, + public statusCode: number, + ) { + super(message); + this.name = "KycError"; + } +} + // #endregion schemas From 80272cd90eda31e16ca6b2220c33d3eb340f3ccd Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 30 Sep 2025 15:35:53 -0300 Subject: [PATCH 13/84] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20invite=20and?= =?UTF-8?q?=20accept=20invite=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/organization-authentication.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index 2b7d2afa4..f565ca086 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -338,3 +338,159 @@ authClient.siwe console.error("nonce error", error); }); ``` + +## How to send an Invite to the Integrator organization + +The integrator address needs to have owner or admin roles. + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { privateKeyToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const domain = "sandbox.exactly.app"; + +const authClient = createAuthClient({ + baseURL: `https://${domain}`, + plugins: [siweClient(), organizationClient()], +}); + +// send invite + +const owner = privateKeyToAccount(process.env.INTEGRATOR_ADMIN_PRIVATE_KEY as `0x${string}`); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + if (!nonceResult) throw new Error("No nonce"); + const statement = `i accept exa terms and conditions`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: nonceResult.nonce, + uri: `https://${domain}`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain, + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const { data, error } = await authClient.organization.inviteMember({ + email: "bob@integrator.com", + role: "admin", + organizationId: "", + fetchOptions: { headers }, + }); + if (!data) { + console.error(error); + return; + } + console.log(`invite id ${data.id}, email ${data.email}. Expires at ${data.expiresAt.toISOString()}`); + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }) + .catch((error: unknown) => { + console.error("nonce error", error); + }); + ``` + +## How to accept an invite from the integrator organization + +Use the invite id generated by the owner or the admin role of the organization and your private key. + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { privateKeyToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const domain = "sandbox.exactly.app"; + +const authClient = createAuthClient({ + baseURL: `https://${domain}`, + plugins: [siweClient(), organizationClient()], +}); + +const owner = privateKeyToAccount(process.env.INTEGRATOR_ADMIN_PRIVATE_KEY as `0x${string}`); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + if (!nonceResult) throw new Error("No nonce"); + const statement = `i accept exa terms and conditions`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: nonceResult.nonce, + uri: `https://${domain}`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain, + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const { data, error } = await authClient.organization.acceptInvitation({ + fetchOptions: { + headers, + }, + invitationId: "", + }); + if (!data) { + console.error(error); + return; + } + console.log(data); + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }) + .catch((error: unknown) => { + console.error("error", error); + }); + ``` From b24fe2ba8d07c1ece7aa4ace41823903c6e28a39 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 2 Oct 2025 10:19:14 -0300 Subject: [PATCH 14/84] =?UTF-8?q?=E2=9C=A8=20server:=20allow=20better=20au?= =?UTF-8?q?th=20users=20to=20change=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/rare-pears-sort.md | 5 +++++ server/utils/auth.ts | 11 ++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 .changeset/rare-pears-sort.md diff --git a/.changeset/rare-pears-sort.md b/.changeset/rare-pears-sort.md new file mode 100644 index 000000000..089ab14bc --- /dev/null +++ b/.changeset/rare-pears-sort.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ allow better auth users to change email diff --git a/server/utils/auth.ts b/server/utils/auth.ts index bcd7bed25..197d82475 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -3,10 +3,13 @@ import { betterAuth } from "better-auth"; import { organization, siwe } from "better-auth/plugins"; import { createAccessControl } from "better-auth/plugins/access"; import { adminAc, defaultStatements, memberAc, ownerAc } from "better-auth/plugins/organization/access"; +import { parse } from "valibot"; import { verifyMessage } from "viem"; import { generateSiweNonce } from "viem/siwe"; import domain from "@exactly/common/domain"; +import chain from "@exactly/common/generated/chain"; +import { Address, Hex } from "@exactly/common/validation"; import authSecret from "./authSecret"; import { authAdapter } from "../database/index"; @@ -20,6 +23,7 @@ export default betterAuth({ database: authAdapter, baseURL: `https://${domain}`, secret: authSecret, + user: { changeEmail: { enabled: true } }, plugins: [ siwe({ domain, @@ -28,12 +32,13 @@ export default betterAuth({ getNonce: async () => { return await Promise.resolve(generateSiweNonce()); }, - verifyMessage: async ({ message, signature, address }) => { + verifyMessage: async ({ message, signature, address, chainId }) => { + if (chainId !== chain.id) return false; try { const isValid = await verifyMessage({ - address: address as `0x${string}`, + address: parse(Address, address), message, - signature: signature as `0x${string}`, + signature: parse(Hex, signature), }); return isValid; } catch (error) { From 714c8e872c80b9c0128dae802d8f976b94432f20 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 2 Oct 2025 11:20:30 -0300 Subject: [PATCH 15/84] =?UTF-8?q?=F0=9F=93=9D=20docs:=20update=20siwe=20au?= =?UTF-8?q?thentication=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/gentle-brooms-say.md | 5 ++ .../docs/organization-authentication.md | 60 +++++++++++++++---- 2 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 .changeset/gentle-brooms-say.md diff --git a/.changeset/gentle-brooms-say.md b/.changeset/gentle-brooms-say.md new file mode 100644 index 000000000..8ecacf81d --- /dev/null +++ b/.changeset/gentle-brooms-say.md @@ -0,0 +1,5 @@ +--- +"@exactly/docs": patch +--- + +📝 update siwe authentication example diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index f565ca086..c27fa4258 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -12,24 +12,25 @@ Better auth client and viem are the recommended libraries to use for authenticat ## SIWE Authentication -Example code to authenticate using SIWE, it will create the user if doesn't exist. -Note: Check viem account to use a private key instead of a mnemonic. +Example code to authenticate using SIWE, it will create the user if doesn't exist with an auto generated email that will be needed +when an admin generates invites. It is possible also to change the auto generated email to a custom one using `authClient.changeEmail` ```typescript import { createAuthClient } from "better-auth/client"; import { siweClient, organizationClient } from "better-auth/client/plugins"; -import { mnemonicToAccount } from "viem/accounts"; +import { privateKeyToAccount } from "viem/accounts"; import { optimismSepolia } from "viem/chains"; import { createSiweMessage } from "viem/siwe"; const chainId = optimismSepolia.id; +const domain = "sandbox.exactly.app"; + const authClient = createAuthClient({ - baseURL: "http://localhost:3000", + baseURL: `https://${domain}`, plugins: [siweClient(), organizationClient()], }); - -const owner = mnemonicToAccount("test test test test test test test test test test test test"); +const owner = privateKeyToAccount(process.env.INTEGRATOR_ADMIN_PRIVATE_KEY as `0x${string}`); authClient.siwe .nonce({ @@ -37,19 +38,19 @@ authClient.siwe chainId, }) .then(async ({ data: nonceResult }) => { + if (!nonceResult) throw new Error("No nonce"); //can be any statement const statement = "i accept exa terms and conditions"; - const nonce = nonceResult?.nonce ?? ""; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], - nonce, - uri: "https://localhost", + nonce: nonceResult.nonce, + uri: `https://${domain}`, address: owner.address, chainId, scheme: "https", version: "1", - domain: "localhost", + domain, }); const signature = await owner.signMessage({ message }); @@ -62,19 +63,54 @@ authClient.siwe }, { onSuccess: async (context) => { + console.log("j", JSON.stringify(context.data, null, 2)); const headers = new Headers(); - headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const cookie = context.response.headers.get("set-cookie"); + if (!cookie) throw new Error("No cookie"); + headers.set("cookie", cookie); + console.log("default email for invites", `${owner.address.toLowerCase()}@https://${domain}`); + console.log("auth cookie", cookie); + const changeEmail = false; + if (changeEmail) { + const { data: changeEmailResult, error: changeEmailError } = await authClient.changeEmail({ + fetchOptions: { + headers, + }, + newEmail: "foo@example.com", + }); + if (changeEmailResult?.status) { + console.log("new email for invites: foo@example.com", changeEmailResult); + } else { + console.error("error changing email", changeEmailError); + } + } }, onError: (context) => { console.log("authorization error", context); }, }, ); - }).catch((error: unknown) => { + }) + .catch((error: unknown) => { console.error("nonce error", error); }); ``` +*Output changeEmail=false:* + +```log +default email for invites 0xd2e4862f5b12888750c3de8bd355a8bea72563db@https://sandbox.exactly.app +auth cookie __Secure-better-auth.session_token=************************.hdFMxm%2B3lfFT1r0PzlAJV1rBu1158FIMNWRCsPyKc20%3D; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax, __cf_bm=xnlWakZTNl.7UbT9hFNiwBoVaynqh_JAAIdKpKD0VxM-1759413526-1.0.1.1-cFxObTiGDHlFoAfPHuU0ha4W_ha9_zwmFWTKcrTC0Zr6MCmtUVGpMLMxH5GX2HiekLpnXFNMJ415sVPuJRO8H2EfywCSEqbulhMxzbYMezw; path=/; expires=Thu, 02-Oct-25 14:28:46 GMT; domain=.sandbox.exactly.app; HttpOnly; Secure; SameSite=None +``` + +*Output changeEmail=true:* + +```log +default email for invites 0xd2e4862f5b12888750c3de8bd355a8bea72563db@https://sandbox.exactly.app +auth cookie __Secure-better-auth.session_token=******************.dHecjPNjsnJ5CyRtsZ%2FovQbtMsDJgpeSWVD2OlycBW4%3D; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=Lax, __cf_bm=dplXTM4T0iJfoIzqnFGZagTOYedVS6a9tIZGoZeomYU-1759413785-1.0.1.1-0fZC9AG_Y9FDvGSmOJKq5r81Vrvw8c_GwHf6Afh_gNMibNFWLbeX6_YFv2F7VDj9FiuavPdCL.yS7h0MSF92asErgnhDUZu4262YzTacY3s; path=/; expires=Thu, 02-Oct-25 14:33:05 GMT; domain=.sandbox.exactly.app; HttpOnly; Secure; SameSite=None +new email for invites: foo@example.com { status: true } +``` + ## Creating an organization owner account will be the owner of the created organization From cce9ee09f0942f246d67d9ed85c36f6bef06801f Mon Sep 17 00:00:00 2001 From: mainqueg Date: Wed, 8 Oct 2025 13:13:30 -0300 Subject: [PATCH 16/84] =?UTF-8?q?=E2=9C=A8=20server:=20return=20card=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/wicked-lies-invent.md | 5 ++ server/api/card.ts | 12 ++- server/test/api/card.test.ts | 135 ++++++++++++++++++++++--------- server/test/e2e.ts | 2 +- 4 files changed, 112 insertions(+), 42 deletions(-) create mode 100644 .changeset/wicked-lies-invent.md diff --git a/.changeset/wicked-lies-invent.md b/.changeset/wicked-lies-invent.md new file mode 100644 index 000000000..64b4f085d --- /dev/null +++ b/.changeset/wicked-lies-invent.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ return card id diff --git a/server/api/card.ts b/server/api/card.ts index 81eb16525..a1d8920e3 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -57,6 +57,7 @@ function createMutex(credentialId: string) { } const CardResponse = object({ + cardId: pipe(string(), uuid(), metadata({ examples: ["123e4567-e89b-12d3-a456-426655440000"] })), displayName: pipe(string(), metadata({ examples: ["John Doe"] })), encryptedPan: object({ data: string(), iv: string() }), encryptedCvc: object({ data: string(), iv: string() }), @@ -83,6 +84,7 @@ const CardResponse = object({ const CreatedCardResponse = object({ lastFour: pipe(string(), metadata({ examples: ["1234"] })), + cardId: pipe(string(), uuid(), metadata({ examples: ["123e4567-e89b-12d3-a456-426655440000"] })), status: pipe(picklist(["ACTIVE", "FROZEN"]), metadata({ examples: ["ACTIVE", "FROZEN"] })), productId: pipe(string(), metadata({ examples: ["402"] })), }); @@ -260,6 +262,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str { ...pan, ...pin, + cardId: id, displayName: `${user.firstName} ${user.lastName}`, expirationMonth, expirationYear, @@ -402,9 +405,12 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str }).catch((error: unknown) => captureException(error)); } return c.json( - { lastFour: card.last4, status: "ACTIVE", productId: SIGNATURE_PRODUCT_ID } satisfies InferOutput< - typeof CreatedCardResponse - >, + { + lastFour: card.last4, + status: "ACTIVE", + cardId: card.id, + productId: SIGNATURE_PRODUCT_ID, + } satisfies InferOutput, 200, ); } catch (error) { diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index e684b6dbc..ffd8d27f4 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -53,18 +53,51 @@ describe("authenticated", () => { pandaId: "404", }, { - id: "frozen", + id: "debit", publicKey, account: padHex("0x4", { size: 20 }), factory: inject("ExaAccountFactory"), + pandaId: "debit", + }, + { + id: "cancel", + publicKey, + account: padHex("0x5", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "cancel", + }, + { + id: "migrate-card-upgraded-plugin", + publicKey, + account: padHex("0x6", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "migrate", + }, + { + id: "migrate-card-non-upgraded-plugin", + publicKey, + account: padHex("0x7", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "migrate", + }, + { + id: "frozen", + publicKey, + account: padHex("0x8", { size: 20 }), + factory: inject("ExaAccountFactory"), pandaId: "frozen", }, ]); await database.insert(cards).values([ - { id: "default", credentialId: "default", lastFour: "1234" }, - { id: "sig", credentialId: "sig", lastFour: "1234", productId: SIGNATURE_PRODUCT_ID }, - { id: "404", credentialId: "404", lastFour: "1234", status: "DELETED" }, - { id: "frozen", credentialId: "frozen", lastFour: "5678", status: "FROZEN" }, + { id: "543c1771-beae-4f26-b662-44ea48b40dc6", credentialId: "default", lastFour: "1234" }, + { + id: "543c1771-beae-4f26-b662-44ea48b40dc7", + credentialId: "sig", + lastFour: "1234", + productId: SIGNATURE_PRODUCT_ID, + }, + { id: "543c1771-beae-4f26-b662-44ea48b40dc8", credentialId: "404", lastFour: "1234", status: "DELETED" }, + { id: "543c1771-beae-4f26-b662-44ea48b40dc9", credentialId: "frozen", lastFour: "5678", status: "FROZEN" }, ]); await Promise.all([ @@ -116,7 +149,7 @@ describe("authenticated", () => { vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); - vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ ...cardTemplate }); vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); const response = await appClient.index.$get( @@ -129,6 +162,7 @@ describe("authenticated", () => { expect(json).toStrictEqual({ ...panTemplate, ...pinTemplate, + cardId: "543c1771-beae-4f26-b662-44ea48b40dc6", displayName: "First Last", expirationMonth: "9", expirationYear: "2029", @@ -160,6 +194,7 @@ describe("authenticated", () => { expect(json).toStrictEqual({ ...panTemplate, ...pinTemplate, + cardId: "543c1771-beae-4f26-b662-44ea48b40dc7", displayName: "First Last", expirationMonth: "9", expirationYear: "2029", @@ -396,34 +431,41 @@ describe("authenticated", () => { }); it("creates a panda debit card with signature product id", async () => { - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCard" }); + const id = "123e4567-e89b-12d3-a456-426655440000"; + + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id }); + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ ...cardTemplate, id }); vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); - const response = await appClient.index.$post({ header: { "test-credential-id": "sig" } }); + const response = await appClient.index.$post({ header: { "test-credential-id": "debit" } }); const json = await response.json(); expect(response.status).toBe(200); const created = await database.query.cards.findFirst({ columns: { mode: true }, - where: eq(cards.credentialId, "sig"), + where: eq(cards.credentialId, "debit"), }); expect(created?.mode).toBe(0); expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "7394", + cardId: id, productId: SIGNATURE_PRODUCT_ID, }); }); it("creates a panda credit card with signature product id", async () => { - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCreditCard", last4: "1224" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ + ...cardTemplate, + id: "123e4567-e89b-12d3-a456-426655440001", + last4: "1224", + }); vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$post({ header: { "test-credential-id": "eth" } }); const json = await response.json(); - expect(response.status).toBe(200); const created = await database.query.cards.findFirst({ @@ -433,7 +475,12 @@ describe("authenticated", () => { expect(created?.mode).toBe(1); - expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "1224", productId: SIGNATURE_PRODUCT_ID }); + expect(json).toStrictEqual({ + status: "ACTIVE", + lastFour: "1224", + cardId: "123e4567-e89b-12d3-a456-426655440001", + productId: SIGNATURE_PRODUCT_ID, + }); }); it("adds user to pax when signature card is issued (upgrade from platinum)", async () => { @@ -513,7 +560,11 @@ describe("authenticated", () => { vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "pax-card", last4: "5555" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ + ...cardTemplate, + id: "123e4567-e89b-12d3-a456-426655440016", + last4: "5555", + }); const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } }); @@ -537,6 +588,7 @@ describe("authenticated", () => { it("does not add user to pax for new signature card (no upgrade)", async () => { const testCredentialId = "new-user-test"; + const cardId = "123e4567-e89b-12d3-a456-426655440017"; await database.insert(credentials).values({ id: testCredentialId, publicKey: new Uint8Array(), @@ -547,23 +599,20 @@ describe("authenticated", () => { vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ - ...cardTemplate, - id: "new-user-card", - last4: "8888", - }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: cardId, last4: "8888" }); const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } }); expect(response.status).toBe(200); const json = await response.json(); - expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "8888", productId: SIGNATURE_PRODUCT_ID }); + expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "8888", cardId, productId: SIGNATURE_PRODUCT_ID }); expect(pax.addCapita).not.toHaveBeenCalled(); }); it("handles pax api error during signature card creation", async () => { const testCredentialId = "pax-error-test"; + const cardId = "123e4567-e89b-12d3-a456-426655440018"; await database.insert(credentials).values({ id: testCredentialId, publicKey: new Uint8Array(), @@ -632,17 +681,19 @@ describe("authenticated", () => { vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockRejectedValueOnce(new Error("pax api error")); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "error-card", last4: "6666" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: cardId, last4: "6666" }); const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } }); expect(response.status).toBe(200); const json = await response.json(); - expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "6666", productId: SIGNATURE_PRODUCT_ID }); + expect(json).toStrictEqual({ status: "ACTIVE", lastFour: "6666", cardId, productId: SIGNATURE_PRODUCT_ID }); }); it("handles missing persona account during signature card creation", async () => { const testCredentialId = "no-account-test"; + const cardId = "123e4567-e89b-12d3-a456-426655440019"; + await database.insert(credentials).values({ id: testCredentialId, publicKey: new Uint8Array(), @@ -661,7 +712,7 @@ describe("authenticated", () => { vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "no-account-card", last4: "7777" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: cardId, last4: "7777" }); const response = await appClient.index.$post({ header: { "test-credential-id": testCredentialId } }); @@ -671,43 +722,47 @@ describe("authenticated", () => { }); it("cancels a card", async () => { - const cardResponse = { ...cardTemplate, id: "cardForCancel", last4: "1224", status: "active" as const }; + const id = "123e4567-e89b-12d3-a456-426655440009"; + const cardResponse = { ...cardTemplate, id, last4: "1224", status: "active" as const }; vi.spyOn(panda, "createCard").mockResolvedValueOnce(cardResponse); vi.spyOn(panda, "updateCard").mockResolvedValueOnce({ ...cardResponse, status: "canceled" }); vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); - const response = await appClient.index.$post({ header: { "test-credential-id": "eth" } }); + const response = await appClient.index.$post({ header: { "test-credential-id": "cancel" } }); const cancelResponse = await appClient.index.$patch({ // @ts-expect-error - bad hono patch type - header: { "test-credential-id": "eth" }, + header: { "test-credential-id": "cancel" }, json: { status: "DELETED" }, }); expect(response.status).toBe(200); expect(cancelResponse.status).toBe(200); - const card = await database.query.cards.findFirst({ - columns: { status: true }, - where: eq(cards.credentialId, "eth"), - }); + const card = await database.query.cards.findFirst({ columns: { status: true }, where: eq(cards.id, id) }); expect(card?.status).toBe("DELETED"); }); describe("migration", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { - await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); + const cardId = "cm-not-uuid"; + const migratedCardId = "123e4567-e89b-12d3-a456-426655440003"; + await database + .insert(cards) + .values([{ id: cardId, credentialId: "migrate-card-upgraded-plugin", lastFour: "1234" }]); vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "getCard").mockRejectedValueOnce(new ServiceError("Panda", 404, "card not found")); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:cm" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: migratedCardId }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); - const response = await appClient.index.$post({ header: { "test-credential-id": "default" } }); + const response = await appClient.index.$post({ + header: { "test-credential-id": "migrate-card-upgraded-plugin" }, + }); - const created = await database.query.cards.findFirst({ where: eq(cards.id, "migration:cm") }); - const deleted = await database.query.cards.findFirst({ where: eq(cards.id, "cm") }); + const created = await database.query.cards.findFirst({ where: eq(cards.id, migratedCardId) }); + const deleted = await database.query.cards.findFirst({ where: eq(cards.id, cardId) }); expect(response.status).toBe(200); expect(created?.status).toBe("ACTIVE"); @@ -715,15 +770,19 @@ describe("authenticated", () => { }); it("creates a panda card having a cm card with invalid uuid", async () => { - await database.insert(cards).values([{ id: "not-uuid", credentialId: "default", lastFour: "1234" }]); + const migratedCardId = "123e4567-e89b-12d3-a456-426655440005"; + const credentialId = "migrate-card-non-upgraded-plugin"; + await database.insert(cards).values([{ id: "not-uuid", credentialId, lastFour: "1234" }]); vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); - vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:not-uuid" }); + vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: migratedCardId }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); - const response = await appClient.index.$post({ header: { "test-credential-id": "default" } }); + const response = await appClient.index.$post({ + header: { "test-credential-id": credentialId }, + }); - const created = await database.query.cards.findFirst({ where: eq(cards.id, "migration:not-uuid") }); + const created = await database.query.cards.findFirst({ where: eq(cards.id, migratedCardId) }); const deleted = await database.query.cards.findFirst({ where: eq(cards.id, "not-uuid") }); expect(response.status).toBe(200); diff --git a/server/test/e2e.ts b/server/test/e2e.ts index 4e2f326d9..c0e0ffc92 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -55,7 +55,7 @@ vi.mock("../utils/panda", async (importOriginal: () => Promise) => ...original, autoCredit: vi.fn().mockResolvedValue(false), createCard: vi.fn().mockImplementation((userId: string) => { - const id = `crd_${Math.random().toString(36).slice(2)}`; + const id = crypto.randomUUID(); const card: Card = { expirationMonth: "12", expirationYear: "2030", From 543b18f95d64cc258b8b61f9405cfd656a3dd048 Mon Sep 17 00:00:00 2001 From: mainqueg Date: Wed, 8 Oct 2025 14:36:01 -0300 Subject: [PATCH 17/84] =?UTF-8?q?=F0=9F=93=9D=20server:=20update=20kyc=20s?= =?UTF-8?q?tatus=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/kyc.ts | 25 ++++++++++++++----------- server/test/api/kyc.test.ts | 8 ++++---- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/server/api/kyc.ts b/server/api/kyc.ts index fc8f35b7e..b0118d46c 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -320,7 +320,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c description: "KYC application submitted successfully", content: { "application/json": { - schema: resolver(object({ id: string(), status: string() }), { errorMode: "ignore" }), + schema: resolver(object({ status: string() }), { errorMode: "ignore" }), }, }, }, @@ -349,7 +349,6 @@ The admin should add a member using [addMember method](https://www.better-auth.c "application/json": { schema: resolver( union([ - object({ code: literal(BadRequestCodes.ALREADY_STARTED) }), object({ code: literal("invalid payload"), message: string(), @@ -363,17 +362,21 @@ The admin should add a member using [addMember method](https://www.better-auth.c }, }, }, + 409: { + description: "Conflict", + content: { + "application/json": { + schema: resolver(object({ code: literal(BadRequestCodes.ALREADY_STARTED) }), { errorMode: "ignore" }), + }, + }, + }, 403: { description: "Forbidden", content: { "application/json": { - schema: resolver( - object({ - code: literal("no permission"), - message: optional(string()), - }), - { errorMode: "ignore" }, - ), + schema: resolver(object({ code: literal("no permission"), message: optional(string()) }), { + errorMode: "ignore", + }), }, }, }, @@ -433,7 +436,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c } if (credential.pandaId) { - return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 401); + return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); } try { const application = await submitApplication(payload, c.req.header("encrypted") === "true"); @@ -441,7 +444,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c .update(credentials) .set({ pandaId: application.id, source }) .where(eq(credentials.id, credentialId)); - return c.json({ id: application.id, status: application.applicationStatus }, 200); + return c.json({ status: application.applicationStatus }, 200); } catch (error) { if (error instanceof KycError) { switch (error.statusCode) { diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 42c45fbc8..c343f127e 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -1174,10 +1174,10 @@ describe("authenticated", () => { }), ); expect(JSON.parse(body as string)).toStrictEqual({ ...applicationPayload, verify }); - await expect(response.json()).resolves.toStrictEqual({ id: "pandaId", status: "approved" }); + await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); }); - it("returns 401 when kyc is already started", async () => { + it("returns 409 when kyc is already started", async () => { const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; const { nonce } = await auth.api.getSiweNonce({ body: { walletAddress: owner.address, chainId: chain.id }, @@ -1209,7 +1209,7 @@ describe("authenticated", () => { { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, ); - expect(response.status).toBe(401); + expect(response.status).toBe(409); await expect(response.json()).resolves.toStrictEqual({ code: "already started", }); @@ -1366,7 +1366,7 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE tag: encryptedPayload.tag.toString("base64"), verify, }); - await expect(response.json()).resolves.toStrictEqual({ id: "pandaId", status: "approved" }); + await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); }); }); }); From 4c7b84e5c7dfa68cfc9b59ef4359e6003e69c0ab Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Fri, 17 Oct 2025 11:43:33 -0300 Subject: [PATCH 18/84] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20patch=20re?= =?UTF-8?q?sponse=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/tidy-worms-sin.md | 5 +++++ server/api/card.ts | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-worms-sin.md diff --git a/.changeset/tidy-worms-sin.md b/.changeset/tidy-worms-sin.md new file mode 100644 index 000000000..c9849c26c --- /dev/null +++ b/.changeset/tidy-worms-sin.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix patch response validation diff --git a/server/api/card.ts b/server/api/card.ts index a1d8920e3..3a8ce4780 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -525,7 +525,15 @@ async function encryptPIN(pin: string) { 404: { description: "Not found", content: { - "application/json": { schema: resolver(object({ code: literal("no card") }), { errorMode: "ignore" }) }, + "application/json": { + schema: resolver( + object({ + code: pipe(literal("no card"), metadata({ examples: ["no card"] })), + legacy: pipe(literal("no card found"), metadata({ examples: ["no card found"] })), + }), + { errorMode: "ignore" }, + ), + }, }, }, }, From 7bec017baf1f3358c3eac8879e8928a01105ed26 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 22 Oct 2025 15:09:18 -0300 Subject: [PATCH 19/84] =?UTF-8?q?=F0=9F=A6=BA=20server:=20improve=20kyc=20?= =?UTF-8?q?application=20response=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/kyc.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/server/api/kyc.ts b/server/api/kyc.ts index b0118d46c..ded8032b5 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -374,9 +374,13 @@ The admin should add a member using [addMember method](https://www.better-auth.c description: "Forbidden", content: { "application/json": { - schema: resolver(object({ code: literal("no permission"), message: optional(string()) }), { - errorMode: "ignore", - }), + schema: resolver( + object({ + code: picklist(["no permission", "no organization"]), + message: optional(string()), + }), + { errorMode: "ignore" }, + ), }, }, }, From c238da7231b8038271c1fc65496c048380bbcf6d Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 22 Oct 2025 15:10:25 -0300 Subject: [PATCH 20/84] =?UTF-8?q?=F0=9F=93=9D=20docs:=20improve=20create?= =?UTF-8?q?=20organization=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/organization-authentication.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index c27fa4258..8adc56b0e 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -116,14 +116,23 @@ new email for invites: foo@example.com { status: true } owner account will be the owner of the created organization ```typescript -const chainId = optimismSepolia.id; +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import type { Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = baseSepolia.id; +const API_BASE_URL = process.env.API_BASE_URL; +if (!API_BASE_URL) throw new Error("API_BASE_URL environment variable is required"); const authClient = createAuthClient({ - baseURL: "http://localhost:3000", + baseURL: process.env.API_BASE_URL, plugins: [siweClient(), organizationClient()], }); -const owner = mnemonicToAccount("test test test test test test test test test test test siwe"); +const owner = privateKeyToAccount(process.env.INTEGRATOR_ADMIN_PRIVATE_KEY as Hex); authClient.siwe .nonce({ @@ -137,12 +146,12 @@ authClient.siwe statement, resources: ["https://exactly.github.io/exa"], nonce, - uri: `https://localhost`, + uri: API_BASE_URL, address: owner.address, chainId, scheme: "https", version: "1", - domain: "localhost", + domain: new URL(API_BASE_URL).hostname, }); const signature = await owner.signMessage({ message }); From 2cd456b85134c14f781db570ea402fe09ff05b9e Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 2 Oct 2025 10:21:24 -0300 Subject: [PATCH 21/84] =?UTF-8?q?=E2=9C=A8=20server:=20improve=20nonce=20u?= =?UTF-8?q?sage=20for=20encrypted=20kyc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cuddly-streets-like.md | 6 + .../docs/organization-authentication.md | 98 ++++------ server/api/kyc.ts | 64 +++--- server/test/api/kyc.test.ts | 185 ++++++++++++------ server/utils/panda.ts | 6 +- 5 files changed, 202 insertions(+), 157 deletions(-) create mode 100644 .changeset/cuddly-streets-like.md diff --git a/.changeset/cuddly-streets-like.md b/.changeset/cuddly-streets-like.md new file mode 100644 index 000000000..336464b4c --- /dev/null +++ b/.changeset/cuddly-streets-like.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +✨ improve nonce usage for encrypted kyc diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index 8adc56b0e..36627f100 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -270,21 +270,14 @@ authClient.siwe ```typescript -import { createAuthClient } from "better-auth/client"; -import { siweClient, organizationClient } from "better-auth/client/plugins"; import crypto from "node:crypto"; import { getAddress, sha256 } from "viem"; import { mnemonicToAccount } from "viem/accounts"; import { optimismSepolia } from "viem/chains"; -import { createSiweMessage } from "viem/siwe"; +import { createSiweMessage, generateSiweNonce } from "viem/siwe"; const chainId = optimismSepolia.id; -const authClient = createAuthClient({ - baseURL: "https://sandbox.exactly.app", - plugins: [siweClient(), organizationClient()], -}); - const owner = mnemonicToAccount("test test test test test test test test test test test siwe"); function encrypt(payload: string) { @@ -323,53 +316,46 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }; } -authClient.siwe - .nonce({ - walletAddress: owner.address, - chainId, - }) - .then(async ({ data: nonceResult }) => { - if (!nonceResult) throw new Error("No nonce"); - const data = { - email: "john.doe@example.com", - lastName: "Doe", - firstName: "John", - nationalId: "123456789", - birthDate: "1990-05-15", - countryOfIssue: "US", - phoneCountryCode: "1", - phoneNumber: "5551234567", - address: { - line1: "123 Main Street", - line2: "Apt 4B", - city: "New York", - region: "NY", - postalCode: "10001", - countryCode: "US", - }, - ipAddress: "192.168.1.100", - occupation: "11-1011", - annualSalary: "75000", - accountPurpose: "Personal Banking", - expectedMonthlyVolume: "5000", - isTermsOfServiceAccepted: true, - }; - const encryptedPayload = encrypt(JSON.stringify(data)); - const exaAccountUserAddress = "0xa7d5e73027844145A538F4bfD7b8d9b41d8B89d3"; - const statement = `I apply for KYC approval on behalf of address ${getAddress(exaAccountUserAddress)} with payload hash ${encryptedPayload.hash}`; - const message = createSiweMessage({ - statement, - resources: ["https://exactly.github.io/exa"], - nonce: nonceResult.nonce, - uri: `https://sandbox.exactly.app`, - address: owner.address, - chainId, - scheme: "https", - version: "1", - domain: "sandbox.exactly.app", - }); - const signature = await owner.signMessage({ message }); - +const data = { + email: "john.doe@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "123456789", + birthDate: "1990-05-15", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main Street", + line2: "Apt 4B", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "192.168.1.100", + occupation: "11-1011", + annualSalary: "75000", + accountPurpose: "Personal Banking", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true, +}; +const encryptedPayload = encrypt(JSON.stringify(data)); +const exaAccountUserAddress = "0xa7d5e73027844145A538F4bfD7b8d9b41d8B89d3"; +const statement = `I apply for KYC approval on behalf of address ${getAddress(exaAccountUserAddress)} with payload hash ${encryptedPayload.hash}`; +const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", +}); +owner.signMessage({ message }) + .then((signature) => { const verify = { message, signature, @@ -380,7 +366,7 @@ authClient.siwe console.log("application payload", { ...payload, verify }); }) .catch((error: unknown) => { - console.error("nonce error", error); + console.error("error", error); }); ``` diff --git a/server/api/kyc.ts b/server/api/kyc.ts index ded8032b5..f931ad678 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -6,20 +6,19 @@ import { Hono } from "hono"; import * as honoOpenapi from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; import { array, literal, metadata, object, optional, parse, picklist, pipe, string, union } from "valibot"; -import { getAddress, sha256 } from "viem"; +import { getAddress, sha256, verifyMessage } from "viem"; import { parseSiweMessage } from "viem/siwe"; import accountInit from "@exactly/common/accountInit"; -import { +import chain, { exaAccountFactoryAddress, exaPluginAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; -import database, { credentials } from "../database/index"; +import database, { credentials, walletAddresses } from "../database/index"; import auth from "../middleware/auth"; -import betterAuth from "../utils/auth"; import decodePublicKey from "../utils/decodePublicKey"; import { SubmitApplicationRequest as Application, @@ -295,15 +294,17 @@ belong to the organization and needs to have *kyc* permission, every owner and a To probe the member of the organization needs to generate a SIWE message with the following statement and viem library is recommended: -"I apply for KYC approval on behalf of [lowercase exa account address]" +"I apply for KYC approval on behalf of address [checksum address] with payload hash [hash]"; + +The hash is sha256(encryptedPayload.ciphertext) The siwe message will be: | fieldName | type | example | notes | |-----------|------|---------|-------| | verify.message | string | SIWE message that includes the statement | | -| verify.signature | string | signature of the message | | -| verify.walletAddress | string | address of the member of the organization that signed the message | | +| verify.signature | Hex | signature of the message | | +| verify.walletAddress | Address | address of the member of the organization that signed the message | | | verify.chainId | number | 11155420 | | A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) @@ -330,7 +331,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c "application/json": { schema: resolver( union([ - object({ code: literal("invalid encryption"), message: string() }), + object({ code: picklist(["invalid encryption", "no account", "bad chain"]), message: string() }), object({ ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, message: optional(array(string())), @@ -391,31 +392,23 @@ The admin should add a member using [addMember method](https://www.better-auth.c vValidator("header", optional(object({ encrypted: optional(string()) })), validatorHook({ debug })), async (c) => { const payload = c.req.valid("json"); - const verifyResponse = await betterAuth.api.verifySiweMessage({ - body: payload.verify, - request: c.req.raw, - asResponse: true, - }); - if (!verifyResponse.ok) { - const errorBody = parse(object({ code: string(), message: string() }), await verifyResponse.json()); - return c.json({ code: "no permission", message: errorBody.message }, 403); + const { message, signature, walletAddress: address } = payload.verify; + + if (!(await verifyMessage({ address, message, signature }))) { + return c.json({ code: "no permission", message: "invalid signature" }, 403); } - const headers = new Headers(); - headers.set("cookie", verifyResponse.headers.get("set-cookie") ?? ""); - const organizations = await betterAuth.api.listOrganizations({ headers }); - const source = organizations[0]?.id; - if (!source) return c.json({ code: "no organization" }, 403); - - const { success: canCreate } = await betterAuth.api.hasPermission({ - headers, - body: { - organizationId: source, - permissions: { - kyc: ["create"], - }, + + const account = await database.query.walletAddresses.findFirst({ + where: eq(walletAddresses.address, address), + with: { + user: { columns: { id: true }, with: { members: { columns: { organizationId: true, role: true } } } }, }, }); - if (!canCreate) return c.json({ code: "no permission" }, 403); + + if (!account) return c.json({ code: "no account", message: `no account found for address ${address}` }, 400); + const member = account.user.members[0]; + if (!member) return c.json({ code: "no organization" }, 403); + if (member.role !== "admin" && member.role !== "owner") return c.json({ code: "no permission" }, 403); const { credentialId } = c.req.valid("cookie"); const credential = await database.query.credentials.findFirst({ @@ -427,6 +420,10 @@ The admin should add a member using [addMember method](https://www.better-auth.c setContext("exa", { credential }); const siweMessage = parseSiweMessage(payload.verify.message); + + if (siweMessage.chainId !== chain.id) + return c.json({ code: "bad chain", message: `expected ${chain.id} but got ${siweMessage.chainId}` }, 400); + const { verify, ...body } = payload; const hash = "ciphertext" in body @@ -439,14 +436,13 @@ The admin should add a member using [addMember method](https://www.better-auth.c return c.json({ code: "no permission", message: "invalid statement" }, 403); } - if (credential.pandaId) { - return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); - } + if (credential.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); + try { const application = await submitApplication(payload, c.req.header("encrypted") === "true"); await database .update(credentials) - .set({ pandaId: application.id, source }) + .set({ pandaId: application.id, source: member.organizationId }) .where(eq(credentials.id, credentialId)); return c.json({ status: application.applicationStatus }, 200); } catch (error) { diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index c343f127e..9cfc11fca 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -7,12 +7,11 @@ import canonicalize from "canonicalize"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; import crypto from "node:crypto"; -import { getAddress, padHex, sha256, zeroAddress, zeroHash } from "viem"; -import { mnemonicToAccount, privateKeyToAddress } from "viem/accounts"; -import { createSiweMessage } from "viem/siwe"; +import { getAddress, sha256 } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { createSiweMessage, generateSiweNonce } from "viem/siwe"; import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; -import deriveAddress from "@exactly/common/deriveAddress"; import chain from "@exactly/common/generated/chain"; import app from "../../api/kyc"; @@ -30,21 +29,6 @@ const appClient = testClient(app); vi.mock("@sentry/node", { spy: true }); describe("authenticated", () => { - const bob = privateKeyToAddress(padHex("0xb0b2")); - const account = deriveAddress(inject("ExaAccountFactory"), { x: padHex(bob), y: zeroHash }); - - beforeAll(async () => { - await database.insert(credentials).values([ - { - id: account, - publicKey: new Uint8Array(), - account, - factory: zeroAddress, - pandaId: "pandaId", - }, - ]); - }); - beforeEach(async () => { await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, "bob")); }); @@ -1019,6 +1003,35 @@ describe("authenticated", () => { describe("with organization", () => { const owner = mnemonicToAccount("test test test test test test test test test test test kyc"); const ownerHeaders: Headers = new Headers(); + const outsider = mnemonicToAccount("test test test test test test test test test test test bob"); + const outsiderHeaders: Headers = new Headers(); + const account = "bob"; + + const applicationPayload = { + email: "test@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "12345678", + birthDate: "1990-01-01", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main St", + city: "New York", + region: "NY", + country: "US", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "127.0.0.1", + occupation: "Engineer", + annualSalary: "100000", + accountPurpose: "Personal", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true as const, + }; + let organizationId: string; beforeAll(async () => { @@ -1039,7 +1052,7 @@ describe("authenticated", () => { domain: "localhost", }); - const adminResponse = await auth.api.verifySiweMessage({ + const ownerLogin = await auth.api.verifySiweMessage({ body: { message: ownerMessage, signature: await owner.signMessage({ message: ownerMessage }), @@ -1049,7 +1062,7 @@ describe("authenticated", () => { request: new Request("https://localhost"), asResponse: true, }); - ownerHeaders.set("cookie", `${adminResponse.headers.get("set-cookie")}`); + ownerHeaders.set("cookie", ownerLogin.headers.get("set-cookie") ?? ""); const externalOrganization = await auth.api.createOrganization({ headers: ownerHeaders, @@ -1060,6 +1073,35 @@ describe("authenticated", () => { }, }); organizationId = externalOrganization?.id ?? ""; + + await auth.api + .getSiweNonce({ + body: { walletAddress: outsider.address, chainId: chain.id }, + }) + .then((result) => { + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: result.nonce, + uri: `https://localhost`, + address: outsider.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + return outsider.signMessage({ message }).then((signature) => { + return auth.api + .verifySiweMessage({ + body: { message, signature, walletAddress: outsider.address, chainId: chain.id }, + request: new Request("https://localhost"), + asResponse: true, + }) + .then((response) => { + outsiderHeaders.set("cookie", response.headers.get("set-cookie") ?? ""); + }); + }); + }); }); describe("status", () => { @@ -1115,14 +1157,14 @@ describe("authenticated", () => { }); it("returns ok when payload is valid and kyc is not started", async () => { - const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; - const { nonce } = await auth.api.getSiweNonce({ - body: { walletAddress: owner.address, chainId: chain.id }, + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], - nonce, + nonce: generateSiweNonce(), uri: `https://sandbox.exactly.app`, address: owner.address, chainId: chain.id, @@ -1178,14 +1220,15 @@ describe("authenticated", () => { }); it("returns 409 when kyc is already started", async () => { - const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; - const { nonce } = await auth.api.getSiweNonce({ - body: { walletAddress: owner.address, chainId: chain.id }, + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], - nonce, + nonce: generateSiweNonce(), uri: `https://sandbox.exactly.app`, address: owner.address, chainId: chain.id, @@ -1231,14 +1274,14 @@ describe("authenticated", () => { }); it("returns 400 if terms of service are not accepted", async () => { - const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; - const { nonce } = await auth.api.getSiweNonce({ - body: { walletAddress: owner.address, chainId: chain.id }, + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(JSON.stringify(canonicalize(applicationPayload)), "utf8"))}`; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], - nonce, + nonce: generateSiweNonce(), uri: `https://sandbox.exactly.app`, address: owner.address, chainId: chain.id, @@ -1292,15 +1335,15 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE } it("returns ok when payload is valid", async () => { - const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); - const statement = `I apply for KYC approval on behalf of address ${getAddress(account)} with payload hash ${sha256(encryptedPayload.ciphertext)}`; - const { nonce } = await auth.api.getSiweNonce({ - body: { walletAddress: owner.address, chainId: chain.id }, + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; const message = createSiweMessage({ statement, resources: ["https://exactly.github.io/exa"], - nonce, + nonce: generateSiweNonce(), uri: `https://sandbox.exactly.app`, address: owner.address, chainId: chain.id, @@ -1368,11 +1411,51 @@ S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE }); await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); }); + + it("returns 403 no organization", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: outsider.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify: { + message, + signature: await outsider.signMessage({ message }), + walletAddress: outsider.address, + chainId: chain.id, + }, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession", encrypted: "true" } }, + ); + + expect(response.status).toBe(403); + }); }); }); describe("update", () => { it("returns ok when kyc is started", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, @@ -1713,29 +1796,3 @@ const inquiry = { }, }, } as const; - -const applicationPayload = { - firstName: "john", - lastName: "doe", - birthDate: "1990-01-15", - nationalId: "123456789", - countryOfIssue: "AA", - email: "john.doe@example.com", - phoneCountryCode: "1", - phoneNumber: "5551234567", - ipAddress: "192.168.1.1", - occupation: "occupation", - annualSalary: "1234", - accountPurpose: "purpose", - expectedMonthlyVolume: "1234", - isTermsOfServiceAccepted: true, - address: { - line1: "123 main street", - line2: "apt 1", - city: "city", - region: "region", - postalCode: "1234", - countryCode: "AA", - country: "country", - }, -} as const; diff --git a/server/utils/panda.ts b/server/utils/panda.ts index 117419a5d..3d557bae3 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -43,7 +43,7 @@ import chain, { upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; -import { Address, Hash } from "@exactly/common/validation"; +import { Address, Hash, Hex } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; import ServiceError from "./ServiceError"; @@ -501,7 +501,7 @@ export const Application = object({ literal(true), metadata({ description: "Whether the user has accepted the terms of service" }), ), - verify: object({ message: string(), signature: string(), walletAddress: string(), chainId: number() }), + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), }); export const SubmitApplicationRequest = union([ @@ -511,7 +511,7 @@ export const SubmitApplicationRequest = union([ iv: string(), ciphertext: string(), tag: string(), - verify: object({ message: string(), signature: string(), walletAddress: string(), chainId: number() }), + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), }), ]); From 1fdf48e9385263c75c6a77f688702976d968d39b Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Fri, 24 Oct 2025 15:52:04 -0300 Subject: [PATCH 22/84] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20configuratio?= =?UTF-8?q?n=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cute-buttons-roll.md | 5 ++ docs/astro.config.ts | 2 +- docs/src/content/docs/resources.md | 76 ++++++++++++++++++++++++++++++ docs/src/content/docs/webhooks.md | 9 ---- 4 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 .changeset/cute-buttons-roll.md create mode 100644 docs/src/content/docs/resources.md diff --git a/.changeset/cute-buttons-roll.md b/.changeset/cute-buttons-roll.md new file mode 100644 index 000000000..67e50d281 --- /dev/null +++ b/.changeset/cute-buttons-roll.md @@ -0,0 +1,5 @@ +--- +"@exactly/docs": patch +--- + +📝 add configuration resources diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 4ffd52f66..ac99ea1f8 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ ]), ], sidebar: [ - { label: "Docs", items: ["index", "webhooks", "organization-authentication"] }, + { label: "Docs", items: ["index", "organization-authentication", "resources", "webhooks"] }, ...openAPISidebarGroups, ], }), diff --git a/docs/src/content/docs/resources.md b/docs/src/content/docs/resources.md new file mode 100644 index 000000000..d5222f505 --- /dev/null +++ b/docs/src/content/docs/resources.md @@ -0,0 +1,76 @@ +--- +title: Resources +sidebar: + label: Resources + order: 10 +--- + +Key resources to setup an integration + +## Exa server IPs + +IP allow listing prevents spoofing, reduces attack surface, and adds network-level security to webhooks. + +### Sandbox - Exa backend OP sepolia + +- 209.38.69.78 +- 143.198.79.59 + +### Sandbox - Exa backend Base sepolia + +- 164.92.75.196 +- 146.190.173.21 + +## Rain RSA public key for card details and pin + +### Sandbox + +``` bash +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCAP192809jZyaw62g/eTzJ3P9H ++RmT88sXUYjQ0K8Bx+rJ83f22+9isKx+lo5UuV8tvOlKwvdDS/pVbzpG7D7NO45c +0zkLOXwDHZkou8fuj8xhDO5Tq3GzcrabNLRLVz3dkx0znfzGOhnY4lkOMIdKxlQb +LuVM/dGDC9UpulF+UwIDAQAB +-----END PUBLIC KEY----- +``` + +### Production + +``` bash +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCeZ9uCoxi2XvOw1VmvVLo88TLk +GE+OO1j3fa8HhYlJZZ7CCIAsaCorrU+ZpD5PUTnmME3DJk+JyY1BB3p8XI+C5uno +QucrbxFbkM1lgR10ewz/LcuhleG0mrXL/bzUZbeJqI6v3c9bXvLPKlsordPanYBG +FZkmBPxc8QEdRgH4awIDAQAB +-----END PUBLIC KEY----- +``` + +## Rain RSA public key for KYC + +### Sandbox + +``` bash +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY----- +``` + +### Production + +``` bash +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2lAlKSFsuOXjP0ULiZcl +Q7y2D5HW7zU/tU9fQLamzWpFw6enJ4eZAaTnk9RRtTG178NR8vl/2WM2qq+WJk+Q +pFyxIaQ0eVvOI/aP3DluF0kHK9u2pSV66Pl06zayIZJu3LAyCkHHoj4pWw7q6rTl +Va8CeJaICTS2g1J6ntVjpCIfUcJAor2OL4W/cimPOJwMdK/sJ5a2v9k85nEX17Xi +IDw8tK44ycj8s/odg0GoZG7B6IsLb2lKFaBLZoWKUqK8vYodcaXj/CMjLteuay7r +lHiLEduzxfqeFvR3s3jJTS6sdUAcLZLk/xXuoNg1pOF5M4JpxMX/TehxlIzaBovd +EQIDAQAB +-----END PUBLIC KEY----- +``` diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index 0d3143bf5..fd806f868 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -35,15 +35,6 @@ const signature = createHmac("sha256", ) Ensure that the computed signature matches the Signature header received in the webhook request before processing the payload. -## Exa server IPs - -IP allow listing prevents spoofing, reduces attack surface, and adds network-level security to webhooks. - -### Sandbox - -- 209.38.69.78 -- 143.198.79.59 - ## Retry policy and timeout An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 200 or times out. From f28f3706b4c64e91e8dd5020af01642ab50bc992 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 28 Oct 2025 16:30:20 -0300 Subject: [PATCH 23/84] =?UTF-8?q?=E2=9C=A8=20server:=20add=20transaction?= =?UTF-8?q?=20receipt=20to=20webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/quick-ants-write.md | 5 ++ docs/src/content/docs/webhooks.md | 64 ++++++++++++++++---- server/hooks/panda.ts | 46 +++++++++++---- server/test/hooks/panda.test.ts | 97 +++++++++++++++++++++++-------- server/utils/keeper.ts | 27 +++++---- 5 files changed, 184 insertions(+), 55 deletions(-) create mode 100644 .changeset/quick-ants-write.md diff --git a/.changeset/quick-ants-write.md b/.changeset/quick-ants-write.md new file mode 100644 index 000000000..48dc1dc7f --- /dev/null +++ b/.changeset/quick-ants-write.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add transaction receipt to webhook diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index fd806f868..a00b24d8c 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -37,7 +37,7 @@ Ensure that the computed signature matches the Signature header received in the ## Retry policy and timeout -An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 200 or times out. +An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 2xx or times out. | Retry Count | Delay (ms) | Delay (seconds) | Delay (minutes) | | --- | --- | --- | --- | @@ -130,12 +130,16 @@ sequenceDiagram Transaction authorized and created with timestamp, for $100.00 amount. -```typescript +```json { "id": "99493687-78c1-4018-8831-d8b1f66f58e2", "timestamp": "2025-08-13T14:36:04.586Z", "resource": "transaction", "action": "created", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, "body": { "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", "type": "spend", @@ -162,12 +166,16 @@ Transaction authorized and created with timestamp, for $100.00 amount. Amount adjusted from $100.00 to $80.00 with status "reversed" and authorizationUpdateAmount of -$20.00 Note that this is a reversal, 1 of the 3 types of refunds. -```typescript +```json { "id": "e7b2853e-4bb7-4428-8dc2-27e604766dfa", "timestamp": "2025-08-12T20:08:37.707Z", "resource": "transaction", "action": "updated", + "receipt": { + "blockNumber": 98, + "transactionHash": "0x8c6ef90db7901c43018b3b079ac5ccf84e9c1eb2aaf0fd5f1f8b3e2b97d25fa3", + }, "body": { "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", "type": "spend", @@ -196,7 +204,7 @@ Note that this is a reversal, 1 of the 3 types of refunds. Final settlement at $80.00 with status "completed". -```typescript +```json { "id": "662eb701-f9ac-4baa-9f86-b341a730c98a", "timestamp": "2025-08-12T20:23:20.662Z", @@ -233,12 +241,16 @@ In a partial capture, the merchant settles for less than the authorized amount. Transaction authorized and created with timestamp for $100.00 amount. -```typescript +```json { "id": "99493687-78c1-4018-8831-d8b1f66f58e2", "timestamp": "2025-08-13T16:37:08.862Z", "resource": "transaction", "action": "created", + "receipt": { + "blockNumber": 108, + "transactionHash": "0x59be2972d1094e6abc14f595b71ed4e9e6ec4e2cd8d61e292f6debcba37e19b4", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -264,12 +276,16 @@ Transaction authorized and created with timestamp for $100.00 amount. Final settlement at $90.00 with status "completed" and timestamp. The final amount is $90 and previously $100 was authorized and captured to the user so $10 is refunded. This is one of the 3 types of refunds. -```typescript +```json { "id": "a79306b2-bbbc-4511-9e58-ca9fbc9a2d9a", "timestamp": "2025-08-13T16:42:28.955Z", "resource": "transaction", "action": "completed", + "receipt": { + "blockNumber": 109, + "transactionHash": "0xd3b27341a97f4621865d896713a82be4099c5e0ad18782fb134fa33a77bba937", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -302,12 +318,16 @@ Certain industries, like restaurants and bars, are allowed to settle for more th Transaction authorized and created with timestamp for $100.00 amount. -```typescript +```json { "id": "9d96c8c9-d10f-4d3a-90b9-978eca13ae2a", "timestamp": "2025-08-13T16:53:21.455Z", "resource": "transaction", "action": "created", + "receipt": { + "blockNumber": 300, + "transactionHash": "0x7faf9d14fde333a946c27f9e173c2d640ef3b4fbafc7e75d2a8a4b8743efb001", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -333,12 +353,16 @@ Transaction authorized and created with timestamp for $100.00 amount. Final settlement at $110.00 with status "completed" and timestamp. Note that the final amount is 110 but 100 was authorized and captured so capturing an extra $10 to the user is needed. -```typescript +```json { "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", "timestamp": "2025-08-13T16:55:11.934Z", "resource": "transaction", "action": "completed", + "receipt": { + "blockNumber": 499, + "transactionHash": "0x2d3a8b61a94f5f36b0d64f3e6a7c5e1bb7eeba6004cd3f1dc7c02b265aec7b02", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -368,12 +392,16 @@ A force capture occurs when a merchant settles a transaction without prior autho #### Transaction completed -```typescript +```json { "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", "timestamp": "2025-08-13T17:00:08.061Z", "resource": "transaction", "action": "completed", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, "body": { "id": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", "type": "spend", @@ -406,7 +434,7 @@ Refunds are treated as negative transactions and may or may not reference the or The webhook is only for informational purpose, Exa does not return funds to the user with this event, is just to notify that a proper refund is coming and do sanity checks. -```typescript +```json { "id": "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", "timestamp": "2025-08-13T17:08:50.609Z", @@ -437,12 +465,16 @@ do sanity checks. Final settlement of -$100.00 with status "completed" and timestamp. Refund $100 to the user. -```typescript +```json { "id": "77474a56-51eb-4918-b09e-73cf20077b1b", "timestamp": "2025-08-13T17:12:48.858Z", "resource": "transaction", "action": "completed", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -494,6 +526,7 @@ Refunds come after the purchase enters a terminal state and could be associated The transaction created webhook is sent when the transaction flow is created, whether it has been authorized or declined. You must persist this information. This event initiates the purchase lifecycle in case of `pending`, then could exist many intermediate state changes done by `transaction update` event and finally the `transaction complete` event sets the purchase in terminal state. No more events coming except of a refund which transaction id could be the same as the original purchase or not. +The onchain receipt will be present only if a onchain transaction is necessary. | field | type | description | example | | --- | --- | --- | --- | @@ -501,6 +534,8 @@ This event initiates the purchase lifecycle in case of `pending`, then could exi | timestamp | string | Time when sent the event. Always the same when retry | 2025-08-06T20:29:23.870Z | | resource | "transaction" | | transaction | | action | "created" | | created | +| receipt?.blockNumber | number | onchain transaction block number | 97 | +| receipt?.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | | body.id | string | Transaction id. Is the same for many events in the life cycle of the purchase | f1083e93-afd5-4271-85c6-dd47099e9746 | | body.type | "spend" | | spend | | body.spend.amount | integer | Amount of the purchase in USD in cents. 1 USD = 100 | 100 | @@ -529,6 +564,8 @@ Triggered for events such as incremental authorizations or reversals (a type of | timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-11T15:30:39.939Z | | resource | "transaction" | | transaction | | action | "updated" | | updated | +| receipt.blockNumber | number | onchain transaction block number | 97 | +| receipt.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | | body.id | string | transaction id. the same in the life cycle of the purchase | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | | body.type | "spend" | | spend | | body.spend.amount | number | amount in usd authorized | 2499 | @@ -551,7 +588,8 @@ Triggered for events such as incremental authorizations or reversals (a type of ### Transaction completed event -This webhook is sent whenever a transaction reaches a final state. Note that the transaction may not have been created before this update. +This webhook is sent whenever a transaction reaches a final state. Note that the transaction may not have been created before this update. The `receipt` exist only +if an onchain transaction is necessary. | field | type | description | example | | --- | --- | --- | --- | @@ -559,6 +597,8 @@ This webhook is sent whenever a transaction reaches a final state. Note that the | timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:29:20.499Z | | resource | "transaction" | | transaction | | action | "completed" | | completed | +| receipt?.blockNumber | number | onchain transaction block number. | 97 | +| receipt?.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | | body.id | string | Is the Transaction id and is the same in the life cycle of the purchase. With refunds could be different from the original purchase. | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | | body.type | "spend" | | spend | | body.spend.amount | number | final settled amount in usd | 1041 | diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 3caa2f045..4abe1d3cb 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -31,6 +31,7 @@ import { toBytes, withRetry, zeroHash, + type TransactionReceipt, } from "viem"; import domain from "@exactly/common/domain"; @@ -248,10 +249,6 @@ export default new Hono().post( setContext("panda", jsonBody); // eslint-disable-line @typescript-eslint/no-unsafe-argument getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `panda.${payload.resource}.${payload.action}`); - startSpan({ name: "webhook", op: "panda.webhook" }, () => publish(payload)).catch((error: unknown) => - captureException(error), - ); - if (payload.resource !== "transaction") { if (payload.resource === "dispute") return c.json({ code: "ok" }); const pandaId = @@ -266,6 +263,9 @@ export default new Hono().post( where: eq(credentials.pandaId, pandaId), }); if (user) setUser({ id: user.account }); + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); } return c.json({ code: "ok" }); } @@ -549,6 +549,10 @@ export default new Hono().post( }, ])); }, + onReceipt: (receipt) => + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => + publish(payload, receipt), + ).catch((error: unknown) => captureException(error, { level: "error" })), }, ); sendPushNotification({ @@ -641,8 +645,8 @@ export default new Hono().post( where: eq(cards.id, payload.body.spend.cardId), with: { credential: { columns: { account: true, id: true } } }, }); - if (!card) return c.json({ code: "card not found" }, 404); + const account = v.parse(Address, card.credential.account); setUser({ id: account }); @@ -675,6 +679,10 @@ export default new Hono().post( feedback: { type: "authorization", status: "approved" }, }).catch((error: unknown) => captureException(error, { level: "error" })); + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); + return c.json({ code: "ok" }); } if (payload.body.spend.status !== "pending" && payload.action !== "completed") return c.json({ code: "ok" }); @@ -711,6 +719,11 @@ export default new Hono().post( : { type: "settlement", status: "settled" }), }, }).catch((error: unknown) => captureException(error, { level: "error" })); + + startSpan({ name: "webhook", op: `panda.webhook.${payload.body.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); + return c.json({ code: "ok" }); } try { @@ -761,6 +774,10 @@ export default new Hono().post( }, ])); }, + onReceipt: (receipt) => + startSpan({ name: "webhook", op: `panda.webhook.${payload.body.id}` }, () => + publish(payload, receipt), + ).catch((error: unknown) => captureException(error, { level: "error" })), }, ); @@ -1176,8 +1193,9 @@ const TransactionPayload = v.object( "invalid transaction payload", ); -async function publish(payload: v.InferOutput) { +async function publish(payload: v.InferOutput, receipt?: TransactionReceipt) { if (payload.resource === "transaction" && payload.action === "requested") return; + if (receipt?.status === "reverted") return; if (payload.resource === "dispute") return; if (payload.resource === "card" && payload.action === "notification") return; @@ -1225,10 +1243,7 @@ async function publish(payload: v.InferOutput) { if (error instanceof Error && error.message === "WebhookFailed") { debugWebhook(error.cause); } else { - debugWebhook({ - error: error.message, - payload: webhookPayload, - }); + debugWebhook({ error: error.message, payload: webhookPayload }); } } throw error; @@ -1275,6 +1290,7 @@ async function publish(payload: v.InferOutput) { return sendWebhook( v.parse(Webhook, { ...payload, + ...(receipt && { receipt }), timestamp, }), webhook.transaction?.[payload.action] ?? webhook.url, @@ -1308,6 +1324,13 @@ const BaseWebhook = v.object({ }), }); +const Receipt = v.pipe( + v.object({ blockNumber: v.bigint(), transactionHash: v.string() }), + v.transform((r) => { + return { ...r, blockNumber: Number(r.blockNumber) }; + }), +); + const Webhook = v.variant("resource", [ v.variant("action", [ v.object({ @@ -1315,6 +1338,7 @@ const Webhook = v.variant("resource", [ timestamp: v.pipe(v.string(), v.isoTimestamp()), resource: v.literal("transaction"), action: v.literal("created"), + receipt: v.optional(Receipt), body: v.object({ ...BaseWebhook.entries, spend: v.object({ @@ -1329,6 +1353,7 @@ const Webhook = v.variant("resource", [ timestamp: v.pipe(v.string(), v.isoTimestamp()), resource: v.literal("transaction"), action: v.literal("updated"), + receipt: v.optional(Receipt), body: v.object({ ...BaseWebhook.entries, spend: v.object({ @@ -1348,6 +1373,7 @@ const Webhook = v.variant("resource", [ timestamp: v.pipe(v.string(), v.isoTimestamp()), resource: v.literal("transaction"), action: v.literal("completed"), + receipt: v.optional(Receipt), body: v.object({ ...BaseWebhook.entries, spend: v.object({ diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index f6f634f51..c44e55419 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2168,11 +2168,15 @@ describe("webhooks", () => { it("forwards transaction created", async () => { const cardId = `${webhookAccount}-card`; - - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - } as Response); + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200 } as Response; + } + return fetch(url, init); + }); await appClient.index.$post({ ...transactionCreated, @@ -2181,12 +2185,17 @@ describe("webhooks", () => { body: { ...transactionCreated.json.body, id: cardId, - spend: { ...transactionCreated.json.body.spend, cardId, userId: webhookAccount }, + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + amount: 100, + authorizedAt: new Date().toISOString(), + }, }, }, }); - - await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); @@ -2197,10 +2206,15 @@ describe("webhooks", () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - } as Response); + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200 } as Response; + } + return fetch(url, init); + }); await appClient.index.$post({ ...transactionUpdated, @@ -2208,13 +2222,20 @@ describe("webhooks", () => { ...transactionUpdated.json, body: { ...transactionUpdated.json.body, - id: cardId, - spend: { ...transactionUpdated.json.body.spend, cardId, userId: webhookAccount }, + id: "forward-transaction-updated", + spend: { + ...transactionUpdated.json.body.spend, + cardId, + userId: webhookAccount, + authorizedAt: new Date().toISOString(), + status: "pending", + authorizationUpdateAmount: 98, + }, }, }, }); - await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); @@ -2225,10 +2246,32 @@ describe("webhooks", () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - } as Response); + const fetch = globalThis.fetch; + let publishCounter = 0; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publishCounter++; + return { ok: true, status: 200 } as Response; + } + return fetch(url, init); + }); + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: "forward-transaction-completed", + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + amount: 99, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); await appClient.index.$post({ ...transactionCompleted, @@ -2236,14 +2279,22 @@ describe("webhooks", () => { ...transactionCompleted.json, body: { ...transactionCompleted.json.body, - id: cardId, - spend: { ...transactionCompleted.json.body.spend, cardId, userId: webhookAccount }, + id: "forward-transaction-completed", + spend: { + ...transactionCompleted.json.body.spend, + cardId, + userId: webhookAccount, + postedAt: new Date().toISOString(), + status: "completed", + amount: 99, + authorizedAmount: 99, + }, }, }, }); - await vi.waitUntil(() => mockFetch.mock.calls.length > 1, 10_000); - const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + await vi.waitUntil(() => publishCounter > 1, 60_000); + const options = mockFetch.mock.calls.filter(([url]) => url === "https://exa.test")[1]?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts index 4735ff613..7fb738475 100644 --- a/server/utils/keeper.ts +++ b/server/utils/keeper.ts @@ -59,6 +59,7 @@ export function extender(keeper: WalletClient MaybePromise) | string[]; level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false; onHash?: (hash: Hash) => MaybePromise; + onReceipt?: (receipt: TransactionReceipt) => MaybePromise; }, ) => withScope((scope) => @@ -145,16 +146,22 @@ export function extender(keeper: WalletClient - withRetry(() => traceClient.traceTransaction(hash), { - delay: 1000, - retryCount: 10, - shouldRetry: ({ error }) => error instanceof InvalidInputRpcError, - }).catch((error: unknown) => { - captureException(error, { level: "error" }); - return null; - }), - ); + const [traceResult] = await Promise.allSettled([ + startSpan({ name: "trace transaction", op: "tx.trace" }, () => + withRetry(() => traceClient.traceTransaction(hash), { + delay: 1000, + retryCount: 10, + shouldRetry: ({ error }) => error instanceof InvalidInputRpcError, + }).catch((error: unknown) => { + captureException(error, { level: "error" }); + return null; + }), + ), + Promise.resolve(options?.onReceipt?.(receipt)).catch((error: unknown) => + captureException(error, { level: "error" }), + ), + ]); + const trace = traceResult.status === "fulfilled" ? traceResult.value : null; scope.setContext("tx", { request, receipt, trace }); if (receipt.status !== "success") { if (!trace) throw new Error("no trace"); From 204b1f6457c30778ad85974aa731c1f30f0ac34e Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 11 Nov 2025 14:08:03 -0300 Subject: [PATCH 24/84] =?UTF-8?q?=E2=9C=A8=20server:=20add=20merchant=20ca?= =?UTF-8?q?tegory=20code=20to=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/src/content/docs/webhooks.md | 3 +++ server/hooks/panda.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index a00b24d8c..7f82d8b2b 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -546,6 +546,7 @@ The onchain receipt will be present only if a onchain transaction is necessary. | body.spend.merchantCity? | string | The merchant city | "San Francisco" | | body.spend.merchantCountry? | string | The merchant country | "US" | | body.spend.merchantCategory? | string | The merchant category | "5814 - Quick Payment Service-Fast Food Restaurants" | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | | body.spend.merchantName | string | The merchant name | SQ *BLUE BOTTLE COFFEE | | body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | | body.spend.authorizedAt | string | Time when purchase was authorized in ISO 8601 | 2025-08-06T20:29:23.288Z | @@ -576,6 +577,7 @@ Triggered for events such as incremental authorizations or reversals (a type of | body.spend.merchantCity? | string | city of the merchant | SAN FRANCISCO | | body.spend.merchantCountry? | string | country of the merchant | US | | body.spend.merchantCategory? | string | category of the merchant | 4121 - Taxicabs and Limousines | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | | body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | | body.spend.merchantName | string | name of the merchant | UBER *TRIP | | body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-10T04:28:39.547Z | @@ -609,6 +611,7 @@ if an onchain transaction is necessary. | body.spend.merchantCity? | string | city of the merchant | CAP.FEDERAL | | body.spend.merchantCountry? | string | country of the merchant | AR | | body.spend.merchantCategory? | string | category of the merchant | Recreation Services | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | | body.spend.merchantName | string | name of the merchant | JOCKEY CLUB | | body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | | body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-08T17:55:14.312Z | diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 4abe1d3cb..5cf4faa9c 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1317,6 +1317,7 @@ const BaseWebhook = v.object({ merchantCity: v.nullish(v.pipe(v.string(), v.trim())), merchantCountry: v.nullish(v.pipe(v.string(), v.trim())), merchantCategory: v.nullish(v.pipe(v.string(), v.trim())), + merchantCategoryCode: v.string(), merchantName: v.pipe(v.string(), v.trim()), authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), authorizedAmount: v.nullish(v.number()), From c88e8ff5349b26e19795197f4a88815dffb896d1 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Fri, 12 Dec 2025 16:08:09 -0300 Subject: [PATCH 25/84] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20server:=20add=20r?= =?UTF-8?q?ole=20to=20organization=20database=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/sharp-squids-push.md | 5 +++++ server/database/schema.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/sharp-squids-push.md diff --git a/.changeset/sharp-squids-push.md b/.changeset/sharp-squids-push.md new file mode 100644 index 000000000..02c1901c8 --- /dev/null +++ b/.changeset/sharp-squids-push.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🗃️ add role to organization database table diff --git a/server/database/schema.ts b/server/database/schema.ts index 40835c4e8..747c3d46b 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -208,6 +208,7 @@ export const organizations = pgTable("organizations", { logo: text("logo"), createdAt: timestamp("created_at").notNull(), metadata: text("metadata"), + role: text("role"), }); export const members = pgTable( From 1841cffa2f16dfc3db1c4164fff8eacd960e52f7 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 12 Nov 2025 12:27:09 -0300 Subject: [PATCH 26/84] =?UTF-8?q?=E2=9C=A8=20server:=20add=20kyc=20role=20?= =?UTF-8?q?to=20organizations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/upset-seas-sink.md | 5 +++++ server/api/kyc.ts | 14 +++++++++++--- server/test/api/kyc.test.ts | 3 ++- server/utils/auth.ts | 3 +++ 4 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 .changeset/upset-seas-sink.md diff --git a/.changeset/upset-seas-sink.md b/.changeset/upset-seas-sink.md new file mode 100644 index 000000000..e46154ae8 --- /dev/null +++ b/.changeset/upset-seas-sink.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add kyc role to organizations diff --git a/server/api/kyc.ts b/server/api/kyc.ts index f931ad678..cad55259d 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -397,11 +397,18 @@ The admin should add a member using [addMember method](https://www.better-auth.c if (!(await verifyMessage({ address, message, signature }))) { return c.json({ code: "no permission", message: "invalid signature" }, 403); } - const account = await database.query.walletAddresses.findFirst({ where: eq(walletAddresses.address, address), with: { - user: { columns: { id: true }, with: { members: { columns: { organizationId: true, role: true } } } }, + user: { + columns: { id: true }, + with: { + members: { + columns: { role: true }, + with: { organization: { columns: { id: true, role: true } } }, + }, + }, + }, }, }); @@ -409,6 +416,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c const member = account.user.members[0]; if (!member) return c.json({ code: "no organization" }, 403); if (member.role !== "admin" && member.role !== "owner") return c.json({ code: "no permission" }, 403); + if (member.organization.role !== "kyc") return c.json({ code: "no permission" }, 403); const { credentialId } = c.req.valid("cookie"); const credential = await database.query.credentials.findFirst({ @@ -442,7 +450,7 @@ The admin should add a member using [addMember method](https://www.better-auth.c const application = await submitApplication(payload, c.req.header("encrypted") === "true"); await database .update(credentials) - .set({ pandaId: application.id, source: member.organizationId }) + .set({ pandaId: application.id, source: member.organization.id }) .where(eq(credentials.id, credentialId)); return c.json({ status: application.applicationStatus }, 200); } catch (error) { diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 9cfc11fca..b02b4cecb 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -15,7 +15,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } fr import chain from "@exactly/common/generated/chain"; import app from "../../api/kyc"; -import database, { credentials, sources } from "../../database"; +import database, { credentials, organizations, sources } from "../../database"; import auth from "../../utils/auth"; import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; @@ -1073,6 +1073,7 @@ describe("authenticated", () => { }, }); organizationId = externalOrganization?.id ?? ""; + await database.update(organizations).set({ role: "kyc" }).where(eq(organizations.id, organizationId)); await auth.api .getSiweNonce({ diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 197d82475..ab9090f1b 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -64,6 +64,9 @@ export default betterAuth({ ...memberAc.statements, }), }, + additionalFields: { + role: { type: "string", required: false, input: false }, + }, allowUserToCreateOrganization: () => true, }), ], From d0a5bad0c0fccae721166b42f790278094b578a1 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Mon, 17 Nov 2025 13:27:46 -0300 Subject: [PATCH 27/84] =?UTF-8?q?=F0=9F=9B=82=20=20server:=20add=20auth=20?= =?UTF-8?q?trusted=20origins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/utils/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/utils/auth.ts b/server/utils/auth.ts index ab9090f1b..c4af9bb24 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -22,6 +22,7 @@ const ac = createAccessControl({ export default betterAuth({ database: authAdapter, baseURL: `https://${domain}`, + trustedOrigins: [`https://${domain}`], secret: authSecret, user: { changeEmail: { enabled: true } }, plugins: [ From 9d15a337f5dcdf927737316c27cc0fad986118bf Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 26 Nov 2025 11:40:07 -0300 Subject: [PATCH 28/84] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20webhook=20?= =?UTF-8?q?retries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/long-moons-brake.md | 5 +++++ server/hooks/panda.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/long-moons-brake.md diff --git a/.changeset/long-moons-brake.md b/.changeset/long-moons-brake.md new file mode 100644 index 000000000..adba8b5e5 --- /dev/null +++ b/.changeset/long-moons-brake.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix webhook retries diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 5cf4faa9c..fdf53d3ca 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1224,7 +1224,7 @@ async function publish(payload: v.InferOutput, receipt?: Transac }, { delay: ({ count }) => Math.trunc(1 << count) * 500, - retryCount: domain === "web.exactly.app" ? 20 : 3, + retryCount: domain === "base-sepolia.exactly.app" ? 3 : 20, shouldRetry: ({ error }) => { if (error instanceof Error) { return error.message === "WebhookFailed" || error.name === "TimeoutError"; From 8a45a69bf0339831cacc373dcf242b2cb0ddcc80 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 26 Nov 2025 11:54:58 -0300 Subject: [PATCH 29/84] =?UTF-8?q?=F0=9F=94=A7=20wagmi:=20use=20exa=20facto?= =?UTF-8?q?ry=20v1.0=20on=20base=20sepolia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/wagmi.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/common/wagmi.config.ts b/common/wagmi.config.ts index 7b94d6a44..fc8b11667 100644 --- a/common/wagmi.config.ts +++ b/common/wagmi.config.ts @@ -93,6 +93,7 @@ export default defineConfig([ exaAccountFactory: { [optimism.id]: "0x961EbA47650e2198A959Ef5f337E542df5E4F61b", + [baseSepolia.id]: "0x343efd4e2073afc4cbe304b5dd50f8d6480e2dd2", }[chainId] ?? "0x98d3E8B291d9E89C25D8371b7e8fFa8BC32E0aEC", }), }, From 4cccfa6bfd9be9c7ee2274e41e454398c5457c68 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 26 Nov 2025 16:41:53 -0300 Subject: [PATCH 30/84] =?UTF-8?q?=F0=9F=A6=BA=20server:=20standardize=20ca?= =?UTF-8?q?rd=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fresh-kings-buy.md | 5 +++++ docs/src/content/docs/webhooks.md | 14 +++++++------- server/hooks/panda.ts | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 .changeset/fresh-kings-buy.md diff --git a/.changeset/fresh-kings-buy.md b/.changeset/fresh-kings-buy.md new file mode 100644 index 000000000..c66ea4d2d --- /dev/null +++ b/.changeset/fresh-kings-buy.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🦺 standardize card status diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index 7f82d8b2b..e802cc637 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -539,10 +539,10 @@ The onchain receipt will be present only if a onchain transaction is necessary. | body.id | string | Transaction id. Is the same for many events in the life cycle of the purchase | f1083e93-afd5-4271-85c6-dd47099e9746 | | body.type | "spend" | | spend | | body.spend.amount | integer | Amount of the purchase in USD in cents. 1 USD = 100 | 100 | -| body.spend.currency | string | Always in usd | usd | +| body.spend.currency | string | Always in usd ISO 4217 | usd | | body.spend.cardId | string | | 47c3c3b3-b197-4a97-ace3-901a6ad7cf61 | | body.spend.localAmount | integer | Purchase amount in local currency | 100 | -| body.spend.localCurrency | string | The local currency | usd, eur, ars | +| body.spend.localCurrency | string | The local currency ISO 4217 | eur | | body.spend.merchantCity? | string | The merchant city | "San Francisco" | | body.spend.merchantCountry? | string | The merchant country | "US" | | body.spend.merchantCategory? | string | The merchant category | "5814 - Quick Payment Service-Fast Food Restaurants" | @@ -570,10 +570,10 @@ Triggered for events such as incremental authorizations or reversals (a type of | body.id | string | transaction id. the same in the life cycle of the purchase | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | | body.type | "spend" | | spend | | body.spend.amount | number | amount in usd authorized | 2499 | -| body.spend.currency | string | always dollars | usd | +| body.spend.currency | string | always dollars ISO 4217 | usd | | body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | | body.spend.localAmount | number | amount in local currency authorized | 2499 | -| body.spend.localCurrency | string | currency of the purchase | usd | +| body.spend.localCurrency | string | currency of the purchase ISO 4217 | usd | | body.spend.merchantCity? | string | city of the merchant | SAN FRANCISCO | | body.spend.merchantCountry? | string | country of the merchant | US | | body.spend.merchantCategory? | string | category of the merchant | 4121 - Taxicabs and Limousines | @@ -604,10 +604,10 @@ if an onchain transaction is necessary. | body.id | string | Is the Transaction id and is the same in the life cycle of the purchase. With refunds could be different from the original purchase. | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | | body.type | "spend" | | spend | | body.spend.amount | number | final settled amount in usd | 1041 | -| body.spend.currency | string | always dollars | usd | +| body.spend.currency | string | always dollars ISO 4217 | usd | | body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | | body.spend.localAmount | number | final settled amount in local currency | 1270000 | -| body.spend.localCurrency | string | currency of the purchase | ars | +| body.spend.localCurrency | string | currency of the purchase ISO 4217 | ars | | body.spend.merchantCity? | string | city of the merchant | CAP.FEDERAL | | body.spend.merchantCountry? | string | country of the merchant | AR | | body.spend.merchantCategory? | string | category of the merchant | Recreation Services | @@ -650,5 +650,5 @@ This webhook is currently triggered when a user adds their card to a digital wal | body.last4 | string | last 4 digits of the card | 7392 | | body.limit.amount | number | spending limit amount | 1000000 | | body.limit.frequency | "per24HourPeriod" \| "per7DayPeriod" \| "per30DayPeriod" \| "perYearPeriod" | frequency of the spending limit | per7DayPeriod | -| body.status | "notActivated" \| "active" \| "locked" \| "canceled" | current status of the card | active | +| body.status | "ACTIVE" \| "FROZEN" \| "DELETED" | current status of the card | ACTIVE | | body.tokenWallets | ["Apple"] \| ["Google Pay"] | array of token wallets | ["Apple"] | diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index fdf53d3ca..700560549 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1400,7 +1400,7 @@ const Webhook = v.variant("resource", [ amount: v.number(), frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]), }), - status: v.picklist(["notActivated", "active", "locked", "canceled"]), + status: v.picklist(["ACTIVE", "FROZEN", "DELETED"]), tokenWallets: v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))]), }), }), From 2e06696e0a4664985009ff509782961c65f9ab0e Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 4 Feb 2026 09:33:19 -0300 Subject: [PATCH 31/84] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20user=20web?= =?UTF-8?q?hook=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/loud-shoes-visit.md | 5 +++++ server/hooks/panda.ts | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .changeset/loud-shoes-visit.md diff --git a/.changeset/loud-shoes-visit.md b/.changeset/loud-shoes-visit.md new file mode 100644 index 000000000..2ab24c16d --- /dev/null +++ b/.changeset/loud-shoes-visit.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix user webhook routing diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 700560549..a10f54dd2 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1281,11 +1281,24 @@ async function publish(payload: v.InferOutput, receipt?: Transac timestamp, body: { ...payload.body, credentialId: user.id }, }), - webhook.card?.[payload.action] ?? webhook.url, + webhook.user?.[payload.action] ?? webhook.url, webhook.secret, ); case "card": - // falls through + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + body: { + ...payload.body, + status: { active: "ACTIVE", locked: "FROZEN", canceled: "DELETED", notActivated: "INACTIVE" }[ + payload.body.status + ], + }, + }), + webhook.card?.[payload.action] ?? webhook.url, + webhook.secret, + ); case "transaction": return sendWebhook( v.parse(Webhook, { From 46fc7aa0de03e45c051b3b17e1c85d0053cd6144 Mon Sep 17 00:00:00 2001 From: mainqueg Date: Thu, 13 Nov 2025 17:40:55 -0300 Subject: [PATCH 32/84] =?UTF-8?q?=F0=9F=93=9D=20server:=20add=20create=20c?= =?UTF-8?q?ard=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/soft-eggs-count.md | 5 +++++ server/api/card.ts | 16 +++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 .changeset/soft-eggs-count.md diff --git a/.changeset/soft-eggs-count.md b/.changeset/soft-eggs-count.md new file mode 100644 index 000000000..a6de30733 --- /dev/null +++ b/.changeset/soft-eggs-count.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +📝 add create card docs diff --git a/server/api/card.ts b/server/api/card.ts index 3a8ce4780..5325437d9 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -79,14 +79,20 @@ const CardResponse = object({ "perAuthorization", ]), }), - productId: pipe(string(), metadata({ examples: ["402"] })), + productId: pipe( + picklist([PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), + metadata({ examples: [PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID] }), + ), }); const CreatedCardResponse = object({ lastFour: pipe(string(), metadata({ examples: ["1234"] })), cardId: pipe(string(), uuid(), metadata({ examples: ["123e4567-e89b-12d3-a456-426655440000"] })), status: pipe(picklist(["ACTIVE", "FROZEN"]), metadata({ examples: ["ACTIVE", "FROZEN"] })), - productId: pipe(string(), metadata({ examples: ["402"] })), + productId: pipe( + picklist([PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), + metadata({ examples: [PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID] }), + ), }); const UpdateCard = union([ @@ -271,7 +277,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str provider: "panda" as const, status, limit, - productId, + productId: parse(CardResponse.entries.productId, productId), } satisfies InferOutput, 200, ); @@ -487,12 +493,12 @@ async function encryptPIN(pin: string) { secretKeyBase64Buffer, ); const sessionId = secretKeyBase64BufferEncrypted.toString("base64"); - + const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv("aes-128-gcm", Buffer.from(secret, "hex"), iv); const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]); const authTag = cipher.getAuthTag(); - + return { data: Buffer.concat([encrypted, authTag]).toString("base64"), iv: iv.toString("base64"), From 571027188efaa3ce5c0ca11c68715e0261cb651e Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 4 Feb 2026 09:55:13 -0300 Subject: [PATCH 33/84] =?UTF-8?q?=F0=9F=94=A7=20server:=20set=20card=20art?= =?UTF-8?q?=20for=20sandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/hungry-impalas-sing.md | 5 +++++ server/utils/panda.ts | 11 +++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 .changeset/hungry-impalas-sing.md diff --git a/.changeset/hungry-impalas-sing.md b/.changeset/hungry-impalas-sing.md new file mode 100644 index 000000000..67b6435a6 --- /dev/null +++ b/.changeset/hungry-impalas-sing.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🔧 set card art for sandbox diff --git a/server/utils/panda.ts b/server/utils/panda.ts index 3d557bae3..3a52b4cb4 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -70,10 +70,13 @@ export async function createCard(userId: string, productId: typeof PLATINUM_PROD limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, configuration: { productId, - virtualCardArt: { - [PLATINUM_PRODUCT_ID]: "81e42f27affd4e328f19651d4f2b438e", - [SIGNATURE_PRODUCT_ID]: "398c4919514b4ec4927e6a9114a4c816", - }[productId], + virtualCardArt: + baseURL === "https://api-dev.rain.xyz/v1" + ? "0c515d7eb0a140fa8f938f8242b0780a" + : { + [PLATINUM_PRODUCT_ID]: "81e42f27affd4e328f19651d4f2b438e", + [SIGNATURE_PRODUCT_ID]: "398c4919514b4ec4927e6a9114a4c816", + }[productId], }, }), "POST", From 6fcc202a0e0b43ef44c4adbdc2b3240f782be03c Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 3 Dec 2025 15:48:55 -0300 Subject: [PATCH 34/84] =?UTF-8?q?=F0=9F=A5=85=20server:=20improve=20weak?= =?UTF-8?q?=20pin=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/wild-points-lose.md | 5 +++++ server/api/card.ts | 10 +++++++++- server/test/api/card.test.ts | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .changeset/wild-points-lose.md diff --git a/.changeset/wild-points-lose.md b/.changeset/wild-points-lose.md new file mode 100644 index 000000000..e939c74e7 --- /dev/null +++ b/.changeset/wild-points-lose.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🥅 improve weak pin error handling diff --git a/server/api/card.ts b/server/api/card.ts index 5325437d9..df1b92fc2 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -522,6 +522,7 @@ async function encryptPIN(pin: string) { object({ code: literal("bad request") }), object({ code: literal("already set"), mode: number() }), object({ code: literal("already set"), status: picklist(["ACTIVE", "DELETED", "FROZEN"]) }), + object({ code: literal("weak pin") }), ]), { errorMode: "ignore" }, ), @@ -592,7 +593,14 @@ async function encryptPIN(pin: string) { } case "pin": { const { sessionId, data, iv } = patch; - await setPIN(card.id, sessionId, { data, iv }); + try { + await setPIN(card.id, sessionId, { data, iv }); + } catch (error) { + if (error instanceof Error && error.message.includes("Weak PIN")) { + return c.json({ code: "weak pin" }, 400); + } + throw error; + } return c.json({ data, iv } satisfies InferOutput, 200); } } diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index ffd8d27f4..f2f60b0aa 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -744,6 +744,23 @@ describe("authenticated", () => { expect(card?.status).toBe("DELETED"); }); + it("sets an invalid card pin", async () => { + vi.spyOn(panda, "setPIN").mockRejectedValueOnce( + new Error( + `400 {"message":"Weak PIN. Avoid repeating (1111) or sequential (1234) numbers.","error":"BadRequestError","statusCode":400}`, + ), + ); + + const cancelResponse = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": "default" }, + json: { sessionId: "sessionId", data: "data", iv: "iv" }, + }); + + expect(cancelResponse.status).toBe(400); + await expect(cancelResponse.json()).resolves.toStrictEqual({ code: "weak pin" }); + }); + describe("migration", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { const cardId = "cm-not-uuid"; From 696d7f7cd5cdbaf24c550e34a0ad8f1754cf4524 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 10 Dec 2025 12:12:03 -0300 Subject: [PATCH 35/84] =?UTF-8?q?=F0=9F=94=A5=20server:=20remove=20redunda?= =?UTF-8?q?nt=20type=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fast-trees-prove.md | 5 +++++ server/hooks/panda.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/fast-trees-prove.md diff --git a/.changeset/fast-trees-prove.md b/.changeset/fast-trees-prove.md new file mode 100644 index 000000000..f7a773b79 --- /dev/null +++ b/.changeset/fast-trees-prove.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🔥 remove redundant type check diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index a10f54dd2..451f33a95 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1240,7 +1240,7 @@ async function publish(payload: v.InferOutput, receipt?: Transac }); } catch (error) { if (error instanceof Error) { - if (error instanceof Error && error.message === "WebhookFailed") { + if (error.message === "WebhookFailed") { debugWebhook(error.cause); } else { debugWebhook({ error: error.message, payload: webhookPayload }); From c303740ac27306e395499a61b93338489f27230d Mon Sep 17 00:00:00 2001 From: mainqueg Date: Tue, 6 Jan 2026 11:24:35 -0300 Subject: [PATCH 36/84] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20update=20c?= =?UTF-8?q?ard=20webhook=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/dry-peas-ring.md | 6 ++++ docs/src/content/docs/webhooks.md | 2 +- server/hooks/panda.ts | 2 +- server/test/hooks/panda.test.ts | 48 ++++++++++++++++++++++++++++++- 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 .changeset/dry-peas-ring.md diff --git a/.changeset/dry-peas-ring.md b/.changeset/dry-peas-ring.md new file mode 100644 index 000000000..64a76b183 --- /dev/null +++ b/.changeset/dry-peas-ring.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +🐛 fix update card webhook schema diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index e802cc637..161220131 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -651,4 +651,4 @@ This webhook is currently triggered when a user adds their card to a digital wal | body.limit.amount | number | spending limit amount | 1000000 | | body.limit.frequency | "per24HourPeriod" \| "per7DayPeriod" \| "per30DayPeriod" \| "perYearPeriod" | frequency of the spending limit | per7DayPeriod | | body.status | "ACTIVE" \| "FROZEN" \| "DELETED" | current status of the card | ACTIVE | -| body.tokenWallets | ["Apple"] \| ["Google Pay"] | array of token wallets | ["Apple"] | +| body.tokenWallets | ["Apple"] \| ["Google Pay"] \| undefined | array of token wallets | ["Apple"] | diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 451f33a95..244c4144e 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1414,7 +1414,7 @@ const Webhook = v.variant("resource", [ frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]), }), status: v.picklist(["ACTIVE", "FROZEN", "DELETED"]), - tokenWallets: v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))]), + tokenWallets: v.nullish(v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))])), }), }), v.object({ diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index c44e55419..75373fff7 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2300,7 +2300,7 @@ describe("webhooks", () => { expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); - it("forwards card updated", async () => { + it("forwards card updated active", async () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, @@ -2328,6 +2328,33 @@ describe("webhooks", () => { expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); + it("forwards card updated canceled", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json() { + return Promise.resolve({}); + }, + } as Response); + + await appClient.index.$post({ + ...cardCanceled, + json: { + ...cardCanceled.json, + body: { + ...cardCanceled.json.body, + userId: webhookAccount, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + it("forwards user updated", async () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, @@ -2409,6 +2436,25 @@ const cardUpdated = { }, } as const; +const cardCanceled = { + header: { signature: "panda-signature" }, + json: { + id: "31740000-bd68-40c8-a400-5a0131f58800", + resource: "card", + action: "updated", + body: { + id: "f3d8a9c2-4e7b-4a1c-9f2e-8d5c6b3a7e9f", + userId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + type: "virtual", + status: "canceled", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "7392", + expirationMonth: "11", + expirationYear: "2029", + }, + }, +} as const; + const userUpdated = { header: { signature: "panda-signature" }, json: { From 126df7f1b02e0c226bc7de69c2f4a2d7f002813d Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Mon, 9 Feb 2026 15:19:52 -0300 Subject: [PATCH 37/84] =?UTF-8?q?=F0=9F=94=A7=20common:=20map=20base=20dom?= =?UTF-8?q?ains=20to=20panda=20certificates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/pandaCertificate.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/common/pandaCertificate.ts b/common/pandaCertificate.ts index f9b2564d1..c767ec95f 100644 --- a/common/pandaCertificate.ts +++ b/common/pandaCertificate.ts @@ -1,20 +1,26 @@ import domain from "./domain"; -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- ignore empty string */ -export default process.env.EXPO_PUBLIC_PANDA_PUBLIC_KEY || - { - "web.exactly.app": `-----BEGIN PUBLIC KEY----- +const production = `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCeZ9uCoxi2XvOw1VmvVLo88TLk GE+OO1j3fa8HhYlJZZ7CCIAsaCorrU+ZpD5PUTnmME3DJk+JyY1BB3p8XI+C5uno QucrbxFbkM1lgR10ewz/LcuhleG0mrXL/bzUZbeJqI6v3c9bXvLPKlsordPanYBG FZkmBPxc8QEdRgH4awIDAQAB ------END PUBLIC KEY-----`, - "sandbox.exactly.app": `-----BEGIN PUBLIC KEY----- +-----END PUBLIC KEY-----`; + +const sandbox = `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCAP192809jZyaw62g/eTzJ3P9H +RmT88sXUYjQ0K8Bx+rJ83f22+9isKx+lo5UuV8tvOlKwvdDS/pVbzpG7D7NO45c 0zkLOXwDHZkou8fuj8xhDO5Tq3GzcrabNLRLVz3dkx0znfzGOhnY4lkOMIdKxlQb LuVM/dGDC9UpulF+UwIDAQAB ------END PUBLIC KEY-----`, +-----END PUBLIC KEY-----`; + +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- ignore empty string */ +export default process.env.EXPO_PUBLIC_PANDA_PUBLIC_KEY || + { + "web.exactly.app": production, + "base.exactly.app": production, + "base-sepolia.exactly.app": sandbox, + "sandbox.exactly.app": sandbox, }[domain] || `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCu2YOeObkaYiQmc49t2Cnk8syA From 847fbaa4d0c4dad8f049e88f6dc0e36f23f41073 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 11 Feb 2026 11:38:38 -0300 Subject: [PATCH 38/84] =?UTF-8?q?=F0=9F=94=A7=20wagmi:=20remove=20hardcode?= =?UTF-8?q?d=20exa=20factory=20for=20base=20sepolia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/wagmi.config.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/wagmi.config.ts b/common/wagmi.config.ts index fc8b11667..26247f041 100644 --- a/common/wagmi.config.ts +++ b/common/wagmi.config.ts @@ -93,12 +93,13 @@ export default defineConfig([ exaAccountFactory: { [optimism.id]: "0x961EbA47650e2198A959Ef5f337E542df5E4F61b", - [baseSepolia.id]: "0x343efd4e2073afc4cbe304b5dd50f8d6480e2dd2", }[chainId] ?? "0x98d3E8B291d9E89C25D8371b7e8fFa8BC32E0aEC", }), }, { - ...((chainId === base.id || chainId === anvil.id) && { scripts: { exaAccountFactory: "ExaAccountFactory" } }), + ...((chainId === base.id || chainId === baseSepolia.id || chainId === anvil.id) && { + scripts: { exaAccountFactory: "ExaAccountFactory" }, + }), optional: { balancerVault: balancerVault?.address, flashLoanAdapter: flashLoanAdapter?.address, From 3ec3fd5fe5cde1f6672365ddd80965511c1f0125 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Wed, 11 Feb 2026 17:27:26 -0300 Subject: [PATCH 39/84] =?UTF-8?q?=E2=9E=95=20app:=20install=20comlink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cspell.json | 1 + package.json | 2 ++ pnpm-lock.yaml | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/cspell.json b/cspell.json index 37e13eaa4..45b5853c6 100644 --- a/cspell.json +++ b/cspell.json @@ -42,6 +42,7 @@ "codegen", "codepoint", "colocating", + "comlink", "cosealg", "cosekeys", "creds", diff --git a/package.json b/package.json index 6adc10184..27507337c 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "abitype": "^1.2.3", "buffer": "^6.0.3", "burnt": "^0.13.0", + "comlink": "^4.4.2", "date-fns": "^4.1.0", "expo": "^54.0.31", "expo-application": "~7.0.8", @@ -168,6 +169,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@wagmi/core": "catalog:", "abitype>zod": "^4.0.0", + "comlink": "$comlink", "axios@1.13.2": "^1.13.5", "bn.js": "^5.2.3", "esbuild@0.18.20": "^0.25.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28fe6de67..01d612440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,7 @@ overrides: '@isaacs/brace-expansion': ^5.0.1 '@modelcontextprotocol/sdk': ^1.26.0 '@wagmi/core': ^3.2.2 + comlink: ^4.4.2 axios@1.13.2: ^1.13.5 bn.js: ^5.2.3 esbuild@0.18.20: ^0.25.0 @@ -213,6 +214,9 @@ importers: burnt: specifier: ^0.13.0 version: 0.13.0(expo@54.0.31)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0)(utf-8-validate@5.0.10))(react@19.1.0) + comlink: + specifier: ^4.4.2 + version: 4.4.2 date-fns: specifier: ^4.1.0 version: 4.1.0 From ae5237e4b45c4bf2c6a030d42a5a80ca83d47468 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Wed, 11 Feb 2026 17:40:29 -0300 Subject: [PATCH 40/84] =?UTF-8?q?=E2=9C=A8=20app:=20expose=20address=20and?= =?UTF-8?q?=20card=20status=20to=20miniapps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(auth)/_layout.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/(auth)/_layout.tsx b/src/app/(auth)/_layout.tsx index 1984503e0..928648854 100644 --- a/src/app/(auth)/_layout.tsx +++ b/src/app/(auth)/_layout.tsx @@ -1,3 +1,5 @@ +import "../../utils/server"; + import React, { useCallback, useEffect } from "react"; import { Platform } from "react-native"; @@ -6,9 +8,13 @@ import Head from "expo-router/head"; import { sdk } from "@farcaster/miniapp-sdk"; import { useQuery } from "@tanstack/react-query"; +import { getConnection } from "@wagmi/core"; +import { proxy } from "comlink"; +import queryClient from "../../utils/queryClient"; import reportError from "../../utils/reportError"; import useBackgroundColor from "../../utils/useBackgroundColor"; +import exaConfig from "../../utils/wagmi/exa"; import type { Credential } from "@exactly/common/validation"; @@ -20,7 +26,17 @@ export default function OnboardingLayout() { useEffect(() => { if (isLoading || !isFetched) return; - if (isMiniApp) sdk.actions.ready().catch(reportError); + if (isMiniApp) { + sdk.actions + .ready( + // @ts-expect-error ready takes no arguments + proxy({ + getAddress: () => getConnection(exaConfig).address, + hasCard: async () => !!(await queryClient.fetchQuery({ queryKey: ["card", "details"] })), + }), + ) + .catch(reportError); + } SplashScreen.hideAsync().catch(reportError); }, [isFetched, isLoading, isMiniApp]); From 72643aed122693a7a6f3ab41754bccd23de05cef Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 12 Feb 2026 15:35:52 -0300 Subject: [PATCH 41/84] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20kyc=20permis?= =?UTF-8?q?sions=20requirement=20in=20organization=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/src/content/docs/organization-authentication.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index 36627f100..ebb189e9d 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -10,6 +10,9 @@ Then the owner can add members with admin role and those admins will be able to Better auth client and viem are the recommended libraries to use for authentication and signing using SIWE. +> ⚠️ **Note:** +> If you need to perform an encrypted KYC operation, please ask the exa team for `kyc` permissions. + ## SIWE Authentication Example code to authenticate using SIWE, it will create the user if doesn't exist with an auto generated email that will be needed From 11294a89c233e7f315730c6d9d29e9a9930bd729 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 12 Feb 2026 16:08:05 -0300 Subject: [PATCH 42/84] =?UTF-8?q?=F0=9F=A5=85=20server:=20add=20expected?= =?UTF-8?q?=20to=20siwe=20statement=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/kyc.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/api/kyc.ts b/server/api/kyc.ts index cad55259d..92671cdc6 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -437,11 +437,15 @@ The admin should add a member using [addMember method](https://www.better-auth.c "ciphertext" in body ? sha256(Buffer.from(body.ciphertext, "base64")) : sha256(Buffer.from(JSON.stringify(canonicalize(body)), "utf8")); - if ( - siweMessage.statement !== - `I apply for KYC approval on behalf of address ${parse(Address, credential.account)} with payload hash ${hash}` - ) { - return c.json({ code: "no permission", message: "invalid statement" }, 403); + const expected = `I apply for KYC approval on behalf of address ${parse(Address, credential.account)} with payload hash ${hash}`; + if (siweMessage.statement !== expected) { + return c.json( + { + code: "no permission", + message: `invalid statement, expected: [${expected}] but got [${siweMessage.statement}]`, + }, + 403, + ); } if (credential.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); From 62c2088d4fcca388bad0bfd0783df0d1db4d08ce Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Fri, 13 Feb 2026 17:19:11 -0300 Subject: [PATCH 43/84] =?UTF-8?q?=E2=9C=A8=20server:=20support=20factory?= =?UTF-8?q?=20selection=20in=20credential=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/brave-foxes-beam.md | 5 ++ server/api/auth/authentication.ts | 13 ++++- server/api/auth/registration.ts | 16 +++++- server/test/api/auth.test.ts | 95 +++++++++++++++++++++++++++++-- server/utils/createCredential.ts | 9 +-- server/utils/validFactories.ts | 44 ++++++++++++++ 6 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 .changeset/brave-foxes-beam.md create mode 100644 server/utils/validFactories.ts diff --git a/.changeset/brave-foxes-beam.md b/.changeset/brave-foxes-beam.md new file mode 100644 index 000000000..0c98eee23 --- /dev/null +++ b/.changeset/brave-foxes-beam.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ support factory selection in credential creation diff --git a/server/api/auth/authentication.ts b/server/api/auth/authentication.ts index fe2cfb5e2..f5d288a7f 100644 --- a/server/api/auth/authentication.ts +++ b/server/api/auth/authentication.ts @@ -50,6 +50,7 @@ import getIntercomToken from "../../utils/intercom"; import publicClient from "../../utils/publicClient"; import redis from "../../utils/redis"; import validatorHook from "../../utils/validatorHook"; +import validFactories from "../../utils/validFactories"; const Cookie = object({ session_id: optional(pipe(Base64URL, title("Session identifier"), description("HTTP-only cookie."))), @@ -250,6 +251,13 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se validatorHook({ code: "bad session" }), ), vValidator("header", optional(object({ "Client-Fid": optional(pipe(string(), maxLength(36))) }))), + vValidator( + "query", + object({ + factory: optional(pipe(Address, title("Factory"), description("Account factory address."))), + }), + validatorHook({ code: "bad factory" }), + ), vValidator( "json", variant("method", [ @@ -304,6 +312,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se ), async (c) => { const assertion = c.req.valid("json"); + const { factory } = c.req.valid("query"); setContext("auth", assertion); const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); @@ -329,7 +338,8 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se ) { return c.json({ code: "bad authentication", legacy: "bad authentication" }, 400); } - const result = await createCredential(c, assertion.id, { source: c.req.header("Client-Fid") }); + if (factory && !validFactories.has(factory)) return c.json({ code: "bad factory" }, 400); + const result = await createCredential(c, assertion.id, { factory, source: c.req.header("Client-Fid") }); const account = deriveAddress(result.factory, { x: result.x, y: result.y }); const intercomToken = await getIntercomToken(account, result.auth); return c.json( @@ -345,6 +355,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se return c.json({ code: "ouch", legacy: "ouch" }, 500); } } + if (factory && factory !== parse(Address, credential.factory)) return c.json({ code: "bad factory" }, 400); setUser({ id: parse(Address, credential.account) }); let newCounter: number | undefined; diff --git a/server/api/auth/registration.ts b/server/api/auth/registration.ts index acaff073e..77e931c89 100644 --- a/server/api/auth/registration.ts +++ b/server/api/auth/registration.ts @@ -45,6 +45,7 @@ import getIntercomToken from "../../utils/intercom"; import publicClient from "../../utils/publicClient"; import redis from "../../utils/redis"; import validatorHook from "../../utils/validatorHook"; +import validFactories from "../../utils/validFactories"; const Cookie = object({ session_id: optional(pipe(Base64URL, title("Session identifier"), description("HTTP-only cookie."))), @@ -255,6 +256,13 @@ export default new Hono() validatorHook({ code: "bad session" }), ), vValidator("header", optional(object({ "Client-Fid": optional(pipe(string(), maxLength(36))) }))), + vValidator( + "query", + object({ + factory: optional(pipe(Address, title("Factory"), description("Account factory address."))), + }), + validatorHook({ code: "bad factory" }), + ), vValidator( "json", variant("method", [ @@ -304,9 +312,11 @@ export default new Hono() ), async (c) => { const attestation = c.req.valid("json"); + const { factory } = c.req.valid("query"); setContext("auth", attestation); const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); + if (factory && !validFactories.has(factory)) return c.json({ code: "bad factory" }, 400); const challenge = await redis.getdel(sessionId); if (!challenge) return c.json({ code: "no registration", legacy: "no registration" }, 400); @@ -360,7 +370,11 @@ export default new Hono() } try { - const result = await createCredential(c, attestation.id, { webauthn, source: c.req.header("Client-Fid") }); + const result = await createCredential(c, attestation.id, { + factory, + webauthn, + source: c.req.header("Client-Fid"), + }); const account = deriveAddress(result.factory, { x: result.x, y: result.y }); const intercomToken = await getIntercomToken(account, new Date(Date.now() + AUTH_EXPIRY)); return c.json( diff --git a/server/test/api/auth.test.ts b/server/test/api/auth.test.ts index 6e66eeeba..de0581c70 100644 --- a/server/test/api/auth.test.ts +++ b/server/test/api/auth.test.ts @@ -10,11 +10,11 @@ import { testClient } from "hono/testing"; import { decodeJwt } from "jose"; import assert from "node:assert"; import { parse, type InferOutput } from "valibot"; -import { zeroAddress } from "viem"; +import { getAddress, padHex, zeroAddress } from "viem"; import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import * as derive from "@exactly/common/deriveAddress"; -import chain from "@exactly/common/generated/chain"; +import chain, { exaAccountFactoryAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; import app, { type Authentication } from "../../api/auth/authentication"; @@ -22,6 +22,7 @@ import registrationApp from "../../api/auth/registration"; import database, { credentials } from "../../database"; import * as publicClient from "../../utils/publicClient"; import redis from "../../utils/redis"; +import validFactories from "../../utils/validFactories"; import type * as SimpleWebAuthn from "@simplewebauthn/server"; import type * as SimpleWebAuthnHelpers from "@simplewebauthn/server/helpers"; @@ -57,6 +58,7 @@ describe("authentication", () => { clientExtensionResults: {}, type: "public-key", }, + query: {}, }, { headers: { cookie: "session_id=test-session" } }, ); @@ -300,7 +302,7 @@ describe("authentication", () => { vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); const id = "0x1234567890123456789012345678901234567890"; const response = await appClient.index.$post( - { json: { method: "siwe", id, signature: "0xdeadbeef" } }, + { json: { method: "siwe", id, signature: "0xdeadbeef" }, query: {} }, { headers: { cookie: "session_id=test-session", "Client-Fid": "12345" } }, ); @@ -326,7 +328,7 @@ describe("authentication", () => { const id = "0xaBcDef1234567890123456789012345678901234"; const response = await appClient.index.$post( - { json: { method: "siwe", id, signature: "0xdeadbeef" } }, + { json: { method: "siwe", id, signature: "0xdeadbeef" }, query: {} }, { headers: { cookie: "session_id=test-session" } }, ); @@ -352,10 +354,11 @@ describe("authentication", () => { const id = "0xaBcDef1234567890123456789012345678901234"; const response = await appClient.index.$post( - { json: { method: "siwe", id, signature: "0xdeadbeef" } }, + { json: { method: "siwe", id, signature: "0xdeadbeef" }, query: {} }, { headers: { cookie: "session_id=test-session" } }, ); expect(response.status).toBe(400); + expect(await response.json()).toEqual(expect.objectContaining({ code: "bad authentication" })); expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); }); @@ -378,6 +381,87 @@ describe("authentication", () => { expect(secondResponse.status).toBe(400); expect(await secondResponse.json()).toEqual(expect.objectContaining({ code: "no authentication" })); }); + + it("creates a credential with factory using siwe", async () => { + vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); + const factory = [...validFactories].find((f) => f !== exaAccountFactoryAddress); + assert.ok(factory); + const id = "0xFace000000000000000000000000000000000001"; + const response = await appClient.index.$post( + { json: { method: "siwe", id, signature: "0xdeadbeef" }, query: { factory } }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(200); + + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, id), + columns: { factory: true }, + }); + expect(credential?.factory).toBe(factory); + expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); + }); + + it("returns 400 for invalid factory using siwe", async () => { + vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); + const id = "0xFace000000000000000000000000000000000002"; + const response = await appClient.index.$post( + { + json: { method: "siwe", id, signature: "0xdeadbeef" }, + query: { factory: getAddress(padHex("0xdead", { size: 20 })) }, + }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual(expect.objectContaining({ code: "bad factory" })); + expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); + }); + + it("authenticates existing credential with matching factory", async () => { + const factory = parse(Address, inject("ExaAccountFactory")); + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + query: { factory }, + }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(200); + const json = (await response.json()) as InferOutput; + expect(json.factory).toBe(factory); + expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); + }); + + it("returns 400 if factory mismatches existing credential", async () => { + const factory = [...validFactories].find((f) => f !== parse(Address, inject("ExaAccountFactory"))); + assert.ok(factory); + const response = await appClient.index.$post( + { + json: { + method: "webauthn", + id: "dGVzdC1jcmVkLWlk", + rawId: "dGVzdC1jcmVkLWlk", + response: { clientDataJSON: "dGVzdA", authenticatorData: "dGVzdA", signature: "dGVzdA" }, + clientExtensionResults: {}, + type: "public-key", + }, + query: { factory }, + }, + { headers: { cookie: "session_id=test-session" } }, + ); + + expect(response.status).toBe(400); + expect(vi.mocked(redis).getdel.mock.calls).toContainEqual(["test-session"]); + }); }); describe("registration", () => { @@ -482,6 +566,7 @@ describe("registration", () => { expect(secondResponse.status).toBe(400); expect(await secondResponse.json()).toEqual(expect.objectContaining({ code: "no registration" })); }); + it("creates a credential using siwe", async () => { vi.spyOn(publicClient.default, "verifySiweMessage").mockResolvedValue(true); const id = "0x1234567890123456789012345678901234567895"; diff --git a/server/utils/createCredential.ts b/server/utils/createCredential.ts index 9d6652ebe..e5f062bfb 100644 --- a/server/utils/createCredential.ts +++ b/server/utils/createCredential.ts @@ -24,13 +24,14 @@ import type { Context } from "hono"; export default async function createCredential( c: Context, credentialId: C, - options?: { source?: string; webauthn?: WebAuthnCredential }, + options?: { factory?: Address; source?: string; webauthn?: WebAuthnCredential }, ) { + const factory = options?.factory ?? exaAccountFactoryAddress; const publicKey = options?.webauthn?.publicKey ?? (isAddress(credentialId) ? new Uint8Array(hexToBytes(credentialId)) : undefined); if (!publicKey) throw new Error("bad credential"); const { x, y } = decodePublicKey(publicKey); - const account = deriveAddress(exaAccountFactoryAddress, { x, y }); + const account = deriveAddress(factory, { x, y }); setUser({ id: account }); const expires = new Date(Date.now() + AUTH_EXPIRY); @@ -39,7 +40,7 @@ export default async function createCredential( account, id: credentialId, publicKey, - factory: exaAccountFactoryAddress, + factory, transports: options?.webauthn?.transports, counter: options?.webauthn?.counter, source: options?.source, @@ -63,5 +64,5 @@ export default async function createCredential( }).catch((error: unknown) => captureException(error, { level: "error" })), ]); identify({ userId: account }); - return { credentialId, factory: parse(Address, exaAccountFactoryAddress), x, y, auth: expires.getTime() }; + return { credentialId, factory: parse(Address, factory), x, y, auth: expires.getTime() }; } diff --git a/server/utils/validFactories.ts b/server/utils/validFactories.ts new file mode 100644 index 000000000..ef762d233 --- /dev/null +++ b/server/utils/validFactories.ts @@ -0,0 +1,44 @@ +import { encodeAbiParameters, encodePacked, getAddress, keccak256, slice, type Address, type Hash } from "viem"; +import { baseSepolia, optimismSepolia } from "viem/chains"; + +import chain, { exaAccountFactoryAddress } from "@exactly/common/generated/chain"; +import deploy from "@exactly/plugin/deploy.json"; + +const PROXY_INIT_CODE_HASH = "0x21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f" as const; + +const create3Factory: Address = + chain.id === optimismSepolia.id + ? "0xcc3f41204a1324DD91F1Dbfc46208535293A371e" + : chain.id === baseSepolia.id + ? "0x9f275F6D25232FFf082082a53C62C6426c1cc94C" + : "0x93FEC2C00BfE902F733B57c5a6CeeD7CD1384AE1"; + +const admin = getAddress( + (deploy.accounts.admin as Record)[String(chain.id)] ?? deploy.accounts.admin.default, +); + +const validFactories = new Set( + ["1.0.0", "1.1.0"].map((version) => + deriveCreate3( + admin, + keccak256(encodeAbiParameters([{ type: "string" }, { type: "string" }], ["Exa Plugin", version])), + ), + ), +); + +if (!validFactories.has(exaAccountFactoryAddress)) throw new Error("missing latest factory"); + +export default validFactories; + +function deriveCreate3(deployer: Address, salt: Hash): Address { + const proxy = slice( + keccak256( + encodePacked( + ["uint8", "address", "bytes32", "bytes32"], + [0xff, create3Factory, keccak256(encodePacked(["address", "bytes32"], [deployer, salt])), PROXY_INIT_CODE_HASH], + ), + ), + 12, + ); + return getAddress(slice(keccak256(encodePacked(["bytes2", "address", "uint8"], ["0xd694", proxy, 0x01])), 12)); +} From e0b8e5ad676ca60c316f19ddf2dd0b63820aaa85 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Wed, 21 Jan 2026 18:39:44 +0100 Subject: [PATCH 44/84] =?UTF-8?q?=E2=9C=A8=20server:=20poke=20account=20af?= =?UTF-8?q?ter=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 | 191 +++++++++---------------- server/hooks/persona.ts | 9 ++ server/test/hooks/activity.test.ts | 115 +++++---------- server/test/hooks/persona.test.ts | 215 ++++++++++++++++++++++++++++- server/test/utils/keeper.test.ts | 18 +-- server/utils/keeper.ts | 109 ++++++++++++++- 7 files changed, 446 insertions(+), 216 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 4500f0736..514a701d6 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, hexToBigInt, withRetry } from "viem"; +import { bytesToBigInt, hexToBigInt } from "viem"; import { - auditorAbi, exaAccountFactoryAbi, - exaPluginAbi, exaPreviewerAbi, exaPreviewerAddress, - marketAbi, - upgradeableModularAccountAbi, wethAddress, } from "@exactly/common/generated/chain"; import { Address, Hash, Hex } from "@exactly/common/validation"; @@ -95,7 +91,7 @@ export default new Hono().post( category !== "erc1155" && (rawContract?.rawValue && rawContract.rawValue !== "0x" ? hexToBigInt(rawContract.rawValue) > 0n : !!value), ); - const accounts = await database.query.credentials + const accountLookup = await database.query.credentials .findMany({ columns: { account: true, publicKey: true, factory: true }, where: inArray(credentials.account, [...new Set(transfers.map(({ toAddress }) => toAddress))]), @@ -113,9 +109,10 @@ 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 accounts = new Set
(); for (const { toAddress: account, rawContract, value, asset: assetSymbol } of transfers) { - if (!accounts[account]) continue; + if (!accountLookup[account]) continue; if (rawContract?.address && markets.has(rawContract.address)) continue; const asset = rawContract?.address ?? ETH; const underlying = asset === ETH ? WETH : asset; @@ -126,132 +123,78 @@ 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]) }); - } + accounts.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 - }), - }, - { ignore: ["NoBalance()"] }, - ) - .then((receipt) => { - if (receipt) return receipt; - throw new Error("NoBalance()"); - }), + [...accounts] + .flatMap((account) => { + const info = accountLookup[account]; + return info ? [[account, info] as const] : []; + }) + .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 }) => { - if (error instanceof Error && error.message === "NoBalance()") return true; - captureException(error, { level: "error", fingerprint: revertFingerprint(error) }); - return true; - }, + address: factory, + functionName: "createAccount", + args: [0n, [decodePublicKey(publicKey, bytesToBigInt)]], + abi: exaAccountFactoryAbi, }, - ), - ), - ); - for (const result of results) { - if (result.status === "fulfilled") continue; - if (result.reason instanceof Error && result.reason.message === "NoBalance()") { - withScope((captureScope) => { - captureScope.addEventProcessor((event) => { - if (event.exception?.values?.[0]) event.exception.values[0].type = "NoBalance"; - return event; - }); - captureException(result.reason, { - level: "warning", - fingerprint: ["{{ default }}", "NoBalance"], - }); - }); - continue; + ); + track({ event: "AccountFunded", userId: account }); + } catch (error: unknown) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: "account_failed" }); + throw error; + } } - 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"]), + await keeper + .poke(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 7f33f77c5..d8df43e4f 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -32,6 +32,7 @@ import { customer } from "../utils/sardine"; import validatorHook from "../utils/validatorHook"; import type { InferOutput } from "valibot"; + const Session = pipe( object({ type: literal("inquiry-session"), @@ -305,6 +306,14 @@ export default new Hono().post( }).catch((error: unknown) => { captureException(error, { level: "error", extra: { pandaId: id, referenceId } }); }); + keeper + .poke(account.output, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }) + .catch((error: unknown) => captureException(error, { level: "error" })); } else { captureException(new Error("invalid account address"), { extra: { pandaId: id, referenceId, account: credential.account }, diff --git a/server/test/hooks/activity.test.ts b/server/test/hooks/activity.test.ts index ffe1b1a28..7afe05809 100644 --- a/server/test/hooks/activity.test.ts +++ b/server/test/hooks/activity.test.ts @@ -55,39 +55,6 @@ describe("address activity", () => { ]); }); - it("captures no balance once after retries", async () => { - vi.spyOn(publicClient, "getCode").mockResolvedValue("0x1"); - vi.spyOn(keeper, "exaSend").mockImplementation((spanOptions) => - Promise.resolve( - spanOptions.op === "exa.poke" ? null : ({ status: "success" } as Awaited>), - ), - ); - - 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( - () => vi.mocked(captureException).mock.calls.some(([error, hint]) => isNoBalance(error, hint, "warning")), - 26_666, - ); - - expect( - vi.mocked(captureException).mock.calls.filter(([error, hint]) => isNoBalance(error, hint, "warning")), - ).toHaveLength(1); - expect( - vi.mocked(captureException).mock.calls.filter(([error, hint]) => isNoBalance(error, hint, "error")), - ).toHaveLength(0); - expect(response.status).toBe(200); - }); - it("fails with unexpected error", async () => { const getCode = vi.spyOn(publicClient, "getCode"); getCode.mockRejectedValue(new Error("Unexpected")); @@ -139,7 +106,7 @@ describe("address activity", () => { expect(captureException).toHaveBeenCalledWith( new WaitForTransactionReceiptTimeoutError({ hash: zeroHash }), - expect.objectContaining({ level: "error", fingerprint: ["{{ default }}", "unknown"] }), + expect.anything(), ); expect( vi.mocked(captureException).mock.calls.filter(([error, hint]) => isNoBalance(error, hint, "warning")), @@ -303,6 +270,7 @@ describe("address activity", () => { }), }), ); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -334,6 +302,7 @@ describe("address activity", () => { cause: new ContractFunctionRevertedError({ abi: [], functionName: "pokeETH", message: "custom reason" }), }), ); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -365,6 +334,7 @@ describe("address activity", () => { cause: new ContractFunctionRevertedError({ abi: [], data: "0xdeadbeef", functionName: "pokeETH" }), }), ); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -394,6 +364,7 @@ describe("address activity", () => { vi.spyOn(publicClient, "simulateContract").mockRejectedValueOnce( new BaseError("test", { cause: new ContractFunctionRevertedError({ abi: [], functionName: "pokeETH" }) }), ); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -421,6 +392,7 @@ describe("address activity", () => { it("fingerprints shouldRetry as unknown", async () => { vi.spyOn(publicClient, "getCode").mockResolvedValue("0x1"); vi.spyOn(publicClient, "simulateContract").mockRejectedValueOnce(new Error("unexpected")); + await anvilClient.setBalance({ address: account, value: parseEther("5") }); const response = await appClient.index.$post({ ...activityPayload, @@ -492,7 +464,7 @@ describe("address activity", () => { }); it("pokes eth with value when rawValue is 0x", async () => { - const exaSend = vi.spyOn(keeper, "exaSend"); + const poke = vi.spyOn(keeper, "poke"); const deposit = parseEther("5"); await anvilClient.setBalance({ address: account, value: deposit }); @@ -512,22 +484,14 @@ describe("address activity", () => { waitForWETHMarket(account, deposit), ]); - expect( - exaSend.mock.calls.some( - ([spanOptions, request]) => - spanOptions.op === "exa.poke" && - request.address === account && - "functionName" in request && - request.functionName === "pokeETH", - ), - ).toBe(true); + expect(poke.mock.calls.some(([poked]) => poked === account)).toBe(true); expect(market.floatingDepositAssets).toBe(deposit); expect(market.isCollateral).toBe(true); expect(response.status).toBe(200); }); it("pokes eth without value", async () => { - const exaSend = vi.spyOn(keeper, "exaSend"); + const poke = vi.spyOn(keeper, "poke"); const deposit = parseEther("5"); await anvilClient.setBalance({ address: account, value: deposit }); @@ -555,15 +519,7 @@ describe("address activity", () => { waitForWETHMarket(account, deposit), ]); - expect( - exaSend.mock.calls.some( - ([spanOptions, request]) => - spanOptions.op === "exa.poke" && - request.address === account && - "functionName" in request && - request.functionName === "pokeETH", - ), - ).toBe(true); + expect(poke.mock.calls.some(([poked]) => poked === account)).toBe(true); expect(market.floatingDepositAssets).toBe(deposit); expect(market.isCollateral).toBe(true); expect(response.status).toBe(200); @@ -606,7 +562,7 @@ describe("address activity", () => { }); it("pokes token without value", async () => { - const exaSend = vi.spyOn(keeper, "exaSend"); + const poke = vi.spyOn(keeper, "poke"); const weth = parseEther("2"); await keeper.exaSend( { name: "mint", op: "tx.mint" }, @@ -637,23 +593,15 @@ describe("address activity", () => { waitForWETHMarket(account, weth), ]); - expect( - exaSend.mock.calls.some( - ([spanOptions, request]) => - spanOptions.op === "exa.poke" && - request.address === account && - "functionName" in request && - request.functionName === "poke", - ), - ).toBe(true); + expect(poke.mock.calls.some(([poked, options]) => poked === account)).toBe(true); expect(market.floatingDepositAssets).toBe(weth); expect(market.isCollateral).toBe(true); expect(response.status).toBe(200); - }); + }, 0); it("ignores token without value and zero rawValue", async () => { vi.spyOn(publicClient, "getCode").mockResolvedValue("0x1"); - const exaSend = vi.spyOn(keeper, "exaSend"); + const poke = vi.spyOn(keeper, "poke"); const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification"); const token = activityPayload.json.event.activity[1]; @@ -676,17 +624,9 @@ describe("address activity", () => { }, }, }); - await vi.waitUntil(() => exaSend.mock.calls.length > 0, 333).catch(() => undefined); + await vi.waitUntil(() => poke.mock.calls.length > 0, 333).catch(() => undefined); - expect( - exaSend.mock.calls.some( - ([spanOptions, request]) => - spanOptions.op === "exa.poke" && - request.address === account && - "functionName" in request && - request.functionName === "poke", - ), - ).toBe(false); + expect(poke.mock.calls.some(([poked, options]) => poked === account)).toBe(false); expect(sendPushNotification).not.toHaveBeenCalled(); expect(response.status).toBe(200); }); @@ -790,6 +730,27 @@ describe("address activity", () => { expect(sendPushNotification).not.toHaveBeenCalled(); expect(response.status).toBe(200); }); + + it("calls poke with correct ignore option", async () => { + const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue(); + + const response = await appClient.index.$post({ + ...activityPayload, + json: { + ...activityPayload.json, + event: { + ...activityPayload.json.event, + activity: [{ ...activityPayload.json.event.activity[0], toAddress: account }], + }, + }, + }); + + expect(response.status).toBe(200); + + await vi.waitUntil(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 }); + + expect(pokeSpy).toHaveBeenCalledWith(account, { ignore: [`NotAllowed(${account})`] }); + }); }); async function getWETHMarket(account: Address) { @@ -817,7 +778,7 @@ async function waitForWETHMarket(account: Address, floatingDepositAssets: bigint return false; throw error; } - }, 26_666); + }, 26_666_000); } function isNoBalance(error: unknown, hint: unknown, level: "error" | "warning") { diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index 6c3774e02..2ff689109 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,10 @@ describe("persona hook", () => { }); }); - afterEach(() => vi.resetAllMocks()); + afterEach(async () => { + 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 +395,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 +436,206 @@ 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 pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue(); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([ + { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + ]) + .mockResolvedValueOnce(parseEther("2")); + + 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(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 }); + + expect(pokeSpy).toHaveBeenCalledWith(account, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }); + }); + + it("pokes only eth when balance is positive", async () => { + const account = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(privateKeyToAddress(padHex("0x420"))), + y: zeroHash, + }); + const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue(); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([{ asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }]) + .mockResolvedValueOnce(0n); + + 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(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 }); + + expect(pokeSpy).toHaveBeenCalledTimes(1); + expect(pokeSpy).toHaveBeenCalledWith(account, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }); + }); + + it("skips weth when eth balance is positive", async () => { + const account = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(privateKeyToAddress(padHex("0x420"))), + y: zeroHash, + }); + const pokeSpy = vi.spyOn(keeper, "poke").mockResolvedValue(); + + const readContractSpy = vi.spyOn(publicClient, "readContract"); + readContractSpy + .mockResolvedValueOnce([ + { asset: wethAddress, market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + { asset: "0x1234567890123456789012345678901234567890", market: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" }, + ]) + .mockResolvedValueOnce(parseEther("5")) + .mockResolvedValueOnce(parseEther("2")); + + 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(() => pokeSpy.mock.calls.length > 0, { timeout: 5000 }); + + expect(pokeSpy).toHaveBeenCalledTimes(1); + expect(pokeSpy).toHaveBeenCalledWith(account, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }); + }); + + 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" }, + ]) + .mockResolvedValueOnce(0n); + + vi.spyOn(publicClient, "getBalance").mockResolvedValue(0n); + + 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.waitFor( + () => { + expect(exaSendSpy).not.toHaveBeenCalledWith(expect.objectContaining({ op: "exa.poke" }), expect.anything()); + }, + { timeout: 500, interval: 50 }, + ); + }); }); describe("manteca template", () => { @@ -447,6 +655,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/test/utils/keeper.test.ts b/server/test/utils/keeper.test.ts index e5e024425..cc4e0d571 100644 --- a/server/test/utils/keeper.test.ts +++ b/server/test/utils/keeper.test.ts @@ -13,8 +13,8 @@ import keeper from "../../utils/keeper"; import nonceManager from "../../utils/nonceManager"; import publicClient from "../../utils/publicClient"; +import type { Hash, Hex } from "@exactly/common/validation"; import type * as timers from "node:timers/promises"; -import type { Hex } from "viem"; describe("fault tolerance", () => { it("recovers if transaction is missing", async () => { @@ -86,13 +86,13 @@ describe("fault tolerance", () => { const first = keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => blockedHashes.push(hash) }, + { onHash: (hash: Hash) => blockedHashes.push(hash) }, ); await vi.waitUntil(() => blockedHashes.length === 1); const second = keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => blockedHashes.push(hash) }, + { onHash: (hash: Hash) => blockedHashes.push(hash) }, ); const sendBlocked = await Promise.allSettled([first, second]); @@ -132,7 +132,7 @@ describe("fault tolerance", () => { await keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); const mockWaitForTransactionReceipt = vi @@ -142,13 +142,13 @@ describe("fault tolerance", () => { const first = keeper.exaSend( { name: "test transfer 0", op: "test.transfer[0]" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); await setImmediate(); const second = keeper.exaSend( { name: "test transfer 1", op: "test.transfer[1]" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); await setImmediate(); const sendBlocked = await Promise.allSettled([ @@ -158,7 +158,7 @@ describe("fault tolerance", () => { keeper.exaSend( { name: `test transfer ${index + 2}`, op: `test.transfer[${index + 2}]` }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ), ), ]); @@ -175,7 +175,7 @@ describe("fault tolerance", () => { await keeper.exaSend( { name: "test transfer", op: "test.transfer" }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ); await vi.waitUntil( @@ -193,7 +193,7 @@ describe("fault tolerance", () => { keeper.exaSend( { name: `test transfer ${index}`, op: `test.transfer[${index}]` }, { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, - { onHash: (hash) => hashes.push(hash) }, + { onHash: (hash: Hash) => hashes.push(hash) }, ), ), ); diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts index 7fb738475..c4430a371 100644 --- a/server/utils/keeper.ts +++ b/server/utils/keeper.ts @@ -5,6 +5,7 @@ import { parse } from "valibot"; import { createWalletClient, encodeFunctionData, + erc20Abi, getContractError, http, InvalidInputRpcError, @@ -23,11 +24,20 @@ import { import { privateKeyToAccount } from "viem/accounts"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; -import chain from "@exactly/common/generated/chain"; +import chain, { + auditorAbi, + exaPluginAbi, + exaPreviewerAbi, + exaPreviewerAddress, + marketAbi, + upgradeableModularAccountAbi, + wethAddress, +} from "@exactly/common/generated/chain"; import revertReason from "@exactly/common/revertReason"; -import { Hash } from "@exactly/common/validation"; +import { Address, Hash } from "@exactly/common/validation"; import nonceManager from "./nonceManager"; +import { sendPushNotification } from "./onesignal"; import publicClient, { captureRequests, Requests } from "./publicClient"; import revertFingerprint from "./revertFingerprint"; import traceClient from "./traceClient"; @@ -50,8 +60,11 @@ export default createWalletClient({ ), }).extend(extender); +const ETH = parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); +const WETH = parse(Address, wethAddress); + export function extender(keeper: WalletClient) { - return { + const base = { exaSend: async ( spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>, call: Prettify>, @@ -202,4 +215,94 @@ export function extender(keeper: WalletClient { + const combinedAccountAbi = [...exaPluginAbi, ...upgradeableModularAccountAbi, ...auditorAbi, ...marketAbi]; + const marketsByAsset = await publicClient + .readContract({ address: exaPreviewerAddress, functionName: "assets", abi: exaPreviewerAbi }) + .then((p) => new Map(p.map((m) => [parse(Address, m.asset), parse(Address, m.market)]))); + + const assetsToPoke: { asset: Address; market: Address | null }[] = []; + + const settled = await Promise.allSettled([ + publicClient + .getBalance({ address: accountAddress }) + .then((balance): { asset: Address; balance: bigint; market: Address | null } => ({ + asset: ETH, + market: null, + balance, + })), + ...[...marketsByAsset.entries()].map(async ([asset, market]) => ({ + asset, + market, + balance: await publicClient.readContract({ + address: asset, + functionName: "balanceOf", + args: [accountAddress], + abi: erc20Abi, + }), + })), + ]).then((s) => { + return s.flatMap((result) => { + if (result.status === "rejected") { + captureException(result.reason, { level: "error" }); + return []; + } + return [result.value]; + }); + }); + + const hasETH = settled.some((r) => r.asset === ETH && r.balance > 0n); + for (const { asset, market, balance } of settled) { + if (hasETH && asset === WETH) continue; + if (balance > 0n) assetsToPoke.push({ asset, market }); + } + + const pokes = await Promise.allSettled( + assetsToPoke.map(({ asset, market }) => + base.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 }] : []), + ), + ), + ).then((r) => { + return r.flatMap((result) => { + if (result.status === "rejected") { + captureException(result.reason, { level: "error" }); + return []; + } + + return result.value ?? []; + }); + }); + + if (options?.notification && pokes.length > 0) { + sendPushNotification({ + userId: accountAddress, + headings: options.notification.headings, + contents: options.notification.contents, + }).catch((error: unknown) => captureException(error, { level: "error" })); + } + }, + }; } From 69f8d62cd895ad197c2ef98d68a35887008322db Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Fri, 13 Feb 2026 15:05:54 +0100 Subject: [PATCH 45/84] =?UTF-8?q?=E2=9E=95=20server:=20install=20gcp=20dep?= =?UTF-8?q?endencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 429 +++++++++++++++++++++++++++++++++++++++++++- server/package.json | 2 + 2 files changed, 429 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01d612440..83eca9e52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,10 +53,10 @@ catalogs: version: 3.3.4 overrides: - abitype>zod: ^4.0.0 '@isaacs/brace-expansion': ^5.0.1 '@modelcontextprotocol/sdk': ^1.26.0 '@wagmi/core': ^3.2.2 + abitype>zod: ^4.0.0 comlink: ^4.4.2 axios@1.13.2: ^1.13.5 bn.js: ^5.2.3 @@ -727,6 +727,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.12.0) @@ -763,6 +766,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@4.3.5)) async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -3171,6 +3177,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'} @@ -3606,6 +3625,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==} @@ -4143,6 +4165,10 @@ packages: '@pix.js/validator@1.1.0': resolution: {integrity: sha512-NIYcYwuFblA8/cx7YpNdEEujNjKsnA985jsNgIMcYtY2AVUz646IUbisgTgFu7erN7X5eeQGzELgRnFoPcInVw==} + '@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} @@ -4171,6 +4197,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==} @@ -5792,6 +5848,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'} @@ -6258,6 +6318,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: @@ -6897,6 +6963,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -6967,6 +7036,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==} @@ -7673,6 +7745,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'} @@ -7993,9 +8069,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==} @@ -8891,6 +8973,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==} @@ -9025,6 +9111,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==} @@ -9102,6 +9192,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'} @@ -9182,6 +9280,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -9243,6 +9346,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'} @@ -9271,6 +9386,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==} @@ -9490,6 +9609,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'} @@ -9912,6 +10035,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} @@ -10020,6 +10146,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==} @@ -10078,6 +10207,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 @@ -10256,6 +10391,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==} @@ -10308,6 +10446,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==} @@ -10942,6 +11083,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==} @@ -10954,6 +11100,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'} @@ -11042,6 +11192,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'} @@ -11577,6 +11731,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 @@ -12121,6 +12283,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'} @@ -12130,6 +12296,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==} @@ -12521,9 +12691,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'} @@ -12611,6 +12787,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==} @@ -12710,6 +12889,10 @@ packages: resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} 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'} @@ -13445,6 +13628,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 @@ -16519,6 +16706,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 @@ -16892,6 +17097,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': {} @@ -17679,6 +17886,9 @@ snapshots: transitivePeerDependencies: - buffer + '@pkgjs/parseargs@0.11.0': + optional: true + '@pkgr/core@0.1.2': {} '@pkgr/core@0.2.9': {} @@ -17704,6 +17914,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)': @@ -18351,7 +18584,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 @@ -20107,6 +20340,8 @@ snapshots: '@tanstack/store@0.8.0': {} + '@tootallnate/once@2.0.0': {} + '@trysound/sax@0.2.0': {} '@tybys/wasm-util@0.10.1': @@ -20636,6 +20871,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@4.3.5))': + 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@4.3.5) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.0.17(vitest@4.0.17)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -21501,6 +21745,8 @@ snapshots: big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -21598,6 +21844,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: @@ -22373,6 +22621,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 @@ -22574,8 +22824,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 @@ -23958,6 +24219,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: @@ -24108,6 +24374,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: {} @@ -24197,6 +24467,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: {} @@ -24266,6 +24553,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 @@ -24345,6 +24641,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): @@ -24376,6 +24702,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 @@ -24694,6 +25027,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 @@ -25113,6 +25454,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 @@ -25252,6 +25599,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: {} @@ -25307,6 +25658,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 @@ -25461,6 +25823,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: {} @@ -25510,6 +25874,8 @@ snapshots: split: 0.2.10 through: 2.3.8 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -26677,12 +27043,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: @@ -26803,6 +27177,8 @@ snapshots: object-deep-merge@2.0.0: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -27388,6 +27764,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 @@ -28083,12 +28478,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: @@ -28616,8 +29022,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: {} @@ -28728,6 +29140,8 @@ snapshots: structured-headers@0.4.1: {} + stubs@3.0.0: {} + sturdy-websocket@0.2.1: optional: true @@ -28907,6 +29321,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: {} @@ -29564,6 +29987,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/package.json b/server/package.json index f68c1afa3..0c7291f57 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", @@ -44,6 +45,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", "better-auth": "^1.4.18", From aa8f94f775a36b2f1b3662a63d63294303d9b423 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Tue, 20 Jan 2026 17:26:24 +0100 Subject: [PATCH 46/84] =?UTF-8?q?=E2=9C=A8=20server:=20use=20gcp=20kms=20f?= =?UTF-8?q?or=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 + server/hooks/persona.ts | 85 ++++++++------ server/test/hooks/persona.test.ts | 48 +++++++- server/test/utils/gcp.test.ts | 41 +++++++ server/utils/allower.ts | 103 ++++++++++++++++ server/utils/baseExtender.ts | 187 ++++++++++++++++++++++++++++++ server/utils/gcp.ts | 72 ++++++++++++ server/utils/keeper.ts | 172 +-------------------------- server/vitest.config.mts | 4 + 11 files changed, 524 insertions(+), 207 deletions(-) create mode 100644 .changeset/lucky-jokes-change.md create mode 100644 server/test/utils/gcp.test.ts create mode 100644 server/utils/allower.ts create mode 100644 server/utils/baseExtender.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 45b5853c6..27825d301 100644 --- a/cspell.json +++ b/cspell.json @@ -177,6 +177,7 @@ "valibot", "valierror", "valkey", + "valora", "viem", "viewability", "wagmi", diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index d8df43e4f..22fa4aa11 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -13,17 +13,20 @@ import { nullable, object, optional, + parse, pipe, safeParse, string, transform, union, } from "valibot"; +import { withRetry } from "viem"; -import { firewallAbi, firewallAddress } from "@exactly/common/generated/chain"; +import { firewallAddress } from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; import database, { credentials } from "../database/index"; +import allower from "../utils/allower"; import keeper from "../utils/keeper"; import { createUser } from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; @@ -33,6 +36,15 @@ import validatorHook from "../utils/validatorHook"; import type { InferOutput } from "valibot"; +let allowerPromise: ReturnType | undefined; +function getAllower() { + allowerPromise ??= allower().catch((error: unknown) => { + allowerPromise = undefined; + throw error; + }); + return allowerPromise; +} + const Session = pipe( object({ type: literal("inquiry-session"), @@ -276,6 +288,40 @@ export default new Hono().post( if (risk.level === "very_high") return c.json({ code: "very high risk" }, 200); } + const account = parse(Address, credential.account); + if (firewallAddress) { + try { + await getAllower().then((client) => client.allow(account, { ignore: [`AlreadyAllowed(${account})`] })); + } catch (error: unknown) { + captureException(error, { level: "error" }); + return c.json({ code: "firewall error" }, 500); + } + withRetry( + () => + keeper.poke(account, { + notification: { + headings: { en: "Account assets updated" }, + contents: { en: "Your funds are ready to use" }, + }, + }), + { + retryCount: 10, + delay: ({ count }) => Math.trunc(1 << count) * 500, + }, + ).catch((error: unknown) => captureException(error, { level: "error" })); + } + + addCapita({ + birthdate: fields.birthdate.value, + document: fields.identificationNumber.value, + firstName: fields.nameFirst.value, + lastName: fields.nameLast.value, + email: fields.emailAddress.value, + phone: fields.phoneNumber?.value ?? "", + internalId: deriveAssociateId(account), + product: "travel insurance", + }).catch((error: unknown) => captureException(error, { level: "error", extra: { pandaId: id, referenceId } })); + // TODO implement error handling to return 200 if event should not be retried const { id } = await createUser({ accountPurpose: fields.accountPurpose.value, @@ -292,43 +338,6 @@ 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) { - addCapita({ - birthdate: fields.birthdate.value, - document: fields.identificationNumber.value, - firstName: fields.nameFirst.value, - lastName: fields.nameLast.value, - email: fields.emailAddress.value, - phone: fields.phoneNumber?.value ?? "", - internalId: deriveAssociateId(account.output), - product: "travel insurance", - }).catch((error: unknown) => { - captureException(error, { level: "error", extra: { pandaId: id, referenceId } }); - }); - keeper - .poke(account.output, { - notification: { - headings: { en: "Account assets updated" }, - contents: { en: "Your funds are ready to use" }, - }, - }) - .catch((error: unknown) => captureException(error, { level: "error" })); - } else { - captureException(new Error("invalid account address"), { - extra: { pandaId: id, referenceId, account: credential.account }, - level: "error", - }); - } - - if (firewallAddress) { - keeper - .exaSend( - { name: "exa.firewall", op: "exa.firewall", attributes: { account: credential.account, personaShareToken } }, - { address: firewallAddress, functionName: "allow", args: [credential.account, true], abi: firewallAbi }, - ) - .catch((error: unknown) => captureException(error, { level: "error" })); - } addDocument(referenceId, { id_class: { value: fields.identificationClass.value }, id_number: { value: fields.identificationNumber.value }, diff --git a/server/test/hooks/persona.test.ts b/server/test/hooks/persona.test.ts index 2ff689109..e5e86f125 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 mockAllow = vi.fn().mockResolvedValue({}); + +vi.mock("../../utils/allower", () => ({ + default: vi.fn(() => + Promise.resolve({ + allow: mockAllow, + }), + ), +})); +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"; @@ -633,9 +649,39 @@ describe("persona hook", () => { () => { expect(exaSendSpy).not.toHaveBeenCalledWith(expect.objectContaining({ op: "exa.poke" }), expect.anything()); }, - { timeout: 500, interval: 50 }, + { timeout: 100, interval: 20 }, ); }); + + 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" }); + + mockAllow.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/gcp.test.ts b/server/test/utils/gcp.test.ts new file mode 100644 index 000000000..188f745d6 --- /dev/null +++ b/server/test/utils/gcp.test.ts @@ -0,0 +1,41 @@ +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("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..fb848db41 --- /dev/null +++ b/server/utils/allower.ts @@ -0,0 +1,103 @@ +import { KeyManagementServiceClient } from "@google-cloud/kms"; +import { captureException, captureMessage } from "@sentry/node"; +import { gcpHsmToAccount } from "@valora/viem-account-hsm-gcp"; +import { parse } from "valibot"; +import { createWalletClient, http, withRetry } from "viem"; + +import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; +import chain, { firewallAbi, firewallAddress } from "@exactly/common/generated/chain"; + +import baseExtender from "./baseExtender"; +import { GOOGLE_APPLICATION_CREDENTIALS, hasCredentials, initializeGcpCredentials, isRetryableKmsError } from "./gcp"; +import nonceManager from "./nonceManager"; +import { captureRequests, Requests } from "./publicClient"; + +import type { Address } from "@exactly/common/validation"; +import type { HttpTransport, LocalAccount, WalletClient } from "viem"; + +if (!chain.rpcUrls.alchemy.http[0]) throw new Error("missing alchemy rpc url"); +const rpcUrl = chain.rpcUrls.alchemy.http[0]; + +if (!process.env.GCP_PROJECT_ID) throw new Error("GCP_PROJECT_ID is required when using GCP KMS"); +const projectId = process.env.GCP_PROJECT_ID; +if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId)) { + throw new Error("GCP_PROJECT_ID must be a valid GCP project ID format"); +} + +if (!process.env.GCP_KMS_KEY_RING) throw new Error("GCP_KMS_KEY_RING is required when using GCP KMS"); +const keyRing = process.env.GCP_KMS_KEY_RING; +if (!process.env.GCP_KMS_KEY_VERSION) throw new Error("GCP_KMS_KEY_VERSION is required when using GCP KMS"); +const version = process.env.GCP_KMS_KEY_VERSION; +if (!/^\d+$/.test(version)) throw new Error("GCP_KMS_KEY_VERSION must be a numeric version number"); + +export async function getAccount(): Promise { + 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.`, + ); + } + + try { + const account = await withRetry( + () => + gcpHsmToAccount({ + hsmKeyVersion: `projects/${projectId}/locations/us-west2/keyRings/${keyRing}/cryptoKeys/allower/cryptoKeyVersions/${version}`, + kmsClient: new KeyManagementServiceClient({ + keyFilename: GOOGLE_APPLICATION_CREDENTIALS, + }), + }), + { + delay: 2000, + retryCount: 3, + shouldRetry: ({ error }) => isRetryableKmsError(error), + }, + ); + + account.nonceManager = nonceManager; + return account; + } catch (error: unknown) { + captureException(error, { level: "error" }); + throw error; + } +} + +export default async function allower() { + 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: await getAccount(), + }).extend((client: WalletClient) => { + const base = baseExtender(client); + return { + ...base, + allow: async (account: Address, options?: { ignore?: string[] }) => { + if (!firewallAddress) throw new Error("firewall address not configured"); + return base.exaSend( + { forceTransaction: true, name: "firewall.allow", op: "exa.firewall", attributes: { account } }, + { + address: firewallAddress, + functionName: "allow", + args: [account, true], + abi: firewallAbi, + }, + options?.ignore ? { ignore: options.ignore } : undefined, + ); + }, + }; + }); +} diff --git a/server/utils/baseExtender.ts b/server/utils/baseExtender.ts new file mode 100644 index 000000000..3b85898c6 --- /dev/null +++ b/server/utils/baseExtender.ts @@ -0,0 +1,187 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core"; +import { captureException, startSpan, withScope } from "@sentry/node"; +import { setTimeout } from "node:timers/promises"; +import { + encodeFunctionData, + getContractError, + InvalidInputRpcError, + keccak256, + RawContractError, + WaitForTransactionReceiptTimeoutError, + withRetry, + type HttpTransport, + type LocalAccount, + type MaybePromise, + type Prettify, + type TransactionReceipt, + type WalletClient, + type WriteContractParameters, +} from "viem"; + +import chain from "@exactly/common/generated/chain"; +import revertReason from "@exactly/common/revertReason"; + +import nonceManager from "./nonceManager"; +import publicClient from "./publicClient"; +import revertFingerprint from "./revertFingerprint"; +import traceClient from "./traceClient"; + +import type { Hash } from "@exactly/common/validation"; + +export default function baseExtender( + client: WalletClient & { + account: TAccount; + }, +) { + return { + exaSend: async ( + spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>, + call: Prettify>, + options?: { + ignore?: ((reason: string) => MaybePromise) | string[]; + level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false; + onHash?: (hash: Hash) => MaybePromise; + onReceipt?: (receipt: TransactionReceipt) => MaybePromise; + }, + ) => + withScope((scope) => + startSpan({ forceTransaction: true, ...spanOptions }, async (span) => { + try { + scope.setContext("tx", { call }); + span.setAttributes({ + "tx.call": `${call.functionName}(${call.args?.map(String).join(", ") ?? ""})`, + "tx.from": client.account.address, + "tx.to": call.address, + }); + const txOptions = { + type: "eip1559", + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, + gas: 5_000_000n, + } as const; + const { request: writeRequest } = await startSpan({ name: "eth_call", op: "tx.simulate" }, () => + publicClient.simulateContract({ account: client.account, ...txOptions, ...call }), + ); + const { + abi: _, + account: __, + address: ___, + ...request + } = { from: writeRequest.account.address, to: writeRequest.address, ...writeRequest }; + scope.setContext("tx", { request }); + const prepared = await startSpan({ name: "prepare transaction", op: "tx.prepare" }, () => + client.prepareTransactionRequest({ + to: call.address, + data: encodeFunctionData(call), + ...txOptions, + nonceManager, + }), + ); + scope.setContext("tx", { request, prepared }); + span.setAttribute("tx.nonce", prepared.nonce); + const serializedTransaction = await startSpan({ name: "sign transaction", op: "tx.sign" }, () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- viem generic type inference limitation + client.signTransaction(prepared as any), + ); + const hash = keccak256(serializedTransaction); + scope.setContext("tx", { request, prepared, hash }); + span.setAttribute("tx.hash", hash); + const abortController = new AbortController(); + const [, receiptResult] = await Promise.allSettled([ + (async () => { + while (!abortController.signal.aborted) { + await Promise.allSettled([ + startSpan({ name: "send transaction", op: "tx.send" }, () => + publicClient.sendRawTransaction({ serializedTransaction }), + ).catch((error: unknown) => { + captureException(error, { level: "error" }); + throw error; + }), + setTimeout(10_000, null, { signal: abortController.signal }), + ]); + } + })(), + startSpan({ name: "wait for receipt", op: "tx.wait" }, () => + publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }), + ) + .catch((error: unknown) => { + if (error instanceof WaitForTransactionReceiptTimeoutError) { + startSpan( + { name: "nonce reset", op: "tx.reset", attributes: { "tx.nonce": prepared.nonce } }, + (resetSpan) => { + const info = nonceManager.info({ address: client.account.address, chainId: chain.id }); + resetSpan.setAttribute("exa.reset", true); + resetSpan.setAttribute("exa.delta", info.delta); + resetSpan.setAttribute("exa.nonce", info.nonce); + nonceManager.hardReset({ address: client.account.address, chainId: chain.id }); + }, + ); + } + throw error; + }) + .finally(() => { + abortController.abort(); + }), + Promise.resolve(options?.onHash?.(hash)).catch((error: unknown) => + captureException(error, { level: "error" }), + ), + ]); + if (receiptResult.status === "rejected") throw receiptResult.reason; + const receipt = receiptResult.value; + scope.setContext("tx", { request, receipt }); + const [trace] = await Promise.all([ + startSpan({ name: "trace transaction", op: "tx.trace" }, () => + withRetry(() => traceClient.traceTransaction(hash), { + delay: 1000, + retryCount: 10, + shouldRetry: ({ error }) => error instanceof InvalidInputRpcError, + }).catch((error: unknown) => { + captureException(error, { level: "error" }); + return null; + }), + ), + Promise.resolve(options?.onReceipt?.(receipt)).catch((error: unknown) => + captureException(error, { level: "error" }), + ), + ]); + scope.setContext("tx", { request, receipt, trace }); + if (receipt.status !== "success") { + if (!trace) throw new Error("no trace"); + // eslint-disable-next-line @typescript-eslint/only-throw-error -- returns error + throw getContractError(new RawContractError({ data: trace.output }), { ...call, args: call.args ?? [] }); + } + span.setStatus({ code: SPAN_STATUS_OK }); + return receipt; + } catch (error: unknown) { + const reason = revertReason(error, { fallback: "message", withArguments: true }); + if (options?.ignore) { + const ignore = + typeof options.ignore === "function" ? await options.ignore(reason) : options.ignore.includes(reason); + if (ignore) { + span.setAttribute("exa.error", reason); + span.setStatus({ code: SPAN_STATUS_OK }); + return ignore === true ? null : ignore; + } + } + span.setStatus({ code: SPAN_STATUS_ERROR, message: reason }); + const level = + typeof options?.level === "function" ? options.level(reason, error) : (options?.level ?? "error"); + if (level) { + withScope((captureScope) => { + const fingerprint = revertFingerprint(error); + if (fingerprint[1] && fingerprint[1] !== "unknown") { + const type = fingerprint.length > 2 ? `${fingerprint[1]}(${fingerprint[2]})` : fingerprint[1]; + captureScope.addEventProcessor((event) => { + if (event.exception?.values?.[0]) event.exception.values[0].type = type; + return event; + }); + } + captureException(error, { level, fingerprint }); + }); + } + throw error; + } + }), + ), + }; +} diff --git a/server/utils/gcp.ts b/server/utils/gcp.ts new file mode 100644 index 000000000..cadfbde95 --- /dev/null +++ b/server/utils/gcp.ts @@ -0,0 +1,72 @@ +import { access, writeFile } from "node:fs/promises"; +import { number, object, safeParse, string } from "valibot"; + +const DECODING_ITERATIONS = 3; +export const GOOGLE_APPLICATION_CREDENTIALS = "/tmp/gcp-service-account.json"; + +if (!process.env.GCP_BASE64_JSON) throw new Error("GCP_BASE64_JSON is required when using GCP KMS"); +const gcpBase64Json = process.env.GCP_BASE64_JSON; + +let initializationPromise: null | Promise = null; + +export function resetGcpInitialization() { + initializationPromise = null; +} + +export async function initializeGcpCredentials() { + if (initializationPromise) { + return initializationPromise; + } + + initializationPromise = (async () => { + if (await hasCredentials()) { + return; + } + + let json = gcpBase64Json; + for (let index = 0; index < DECODING_ITERATIONS; index++) { + json = Buffer.from(json, "base64").toString("utf8"); + } + await writeFile(GOOGLE_APPLICATION_CREDENTIALS, json, { mode: 0o600 }); + })().catch((error: unknown) => { + initializationPromise = null; + throw error; + }); + + return initializationPromise; +} + +export async function hasCredentials(): Promise { + return access(GOOGLE_APPLICATION_CREDENTIALS) + .then(() => true) + .catch(() => false); +} + +export function isRetryableKmsError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const numericResult = safeParse(object({ code: number() }), error); + if (numericResult.success) { + const code = numericResult.output.code; + return code === 14 || code === 4 || code === 13 || code === 8; + } + + const stringResult = safeParse(object({ code: string() }), error); + if (stringResult.success) { + const code = stringResult.output.code; + return ( + code === "UNAVAILABLE" || code === "DEADLINE_EXCEEDED" || code === "INTERNAL" || 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" + ); +} diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts index c4430a371..b0aa87dba 100644 --- a/server/utils/keeper.ts +++ b/server/utils/keeper.ts @@ -1,25 +1,12 @@ -import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from "@sentry/core"; -import { captureException, startSpan, withScope } from "@sentry/node"; -import { setTimeout } from "node:timers/promises"; +import { captureException } from "@sentry/node"; import { parse } from "valibot"; import { createWalletClient, - encodeFunctionData, erc20Abi, - getContractError, http, - InvalidInputRpcError, - keccak256, - RawContractError, - WaitForTransactionReceiptTimeoutError, - withRetry, type HttpTransport, - type MaybePromise, - type Prettify, type PrivateKeyAccount, - type TransactionReceipt, type WalletClient, - type WriteContractParameters, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; @@ -33,14 +20,12 @@ import chain, { upgradeableModularAccountAbi, wethAddress, } from "@exactly/common/generated/chain"; -import revertReason from "@exactly/common/revertReason"; import { Address, Hash } from "@exactly/common/validation"; +import baseExtender from "./baseExtender"; import nonceManager from "./nonceManager"; import { sendPushNotification } from "./onesignal"; import publicClient, { captureRequests, Requests } from "./publicClient"; -import revertFingerprint from "./revertFingerprint"; -import traceClient from "./traceClient"; if (!chain.rpcUrls.alchemy.http[0]) throw new Error("missing alchemy rpc url"); @@ -64,157 +49,8 @@ const ETH = parse(Address, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); const WETH = parse(Address, wethAddress); export function extender(keeper: WalletClient) { - const base = { - exaSend: async ( - spanOptions: Prettify[0], "name" | "op"> & { name: string; op: string }>, - call: Prettify>, - options?: { - ignore?: ((reason: string) => MaybePromise) | string[]; - level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false; - onHash?: (hash: Hash) => MaybePromise; - onReceipt?: (receipt: TransactionReceipt) => MaybePromise; - }, - ) => - withScope((scope) => - startSpan({ forceTransaction: true, ...spanOptions }, async (span) => { - try { - scope.setContext("tx", { call }); - span.setAttributes({ - "tx.call": `${call.functionName}(${call.args?.map(String).join(", ") ?? ""})`, - "tx.from": keeper.account.address, - "tx.to": call.address, - }); - const txOptions = { - type: "eip1559", - maxFeePerGas: 1_000_000_000n, - maxPriorityFeePerGas: 1_000_000n, - gas: 5_000_000n, - } as const; - const { request: writeRequest } = await startSpan({ name: "eth_call", op: "tx.simulate" }, () => - publicClient.simulateContract({ account: keeper.account, ...txOptions, ...call }), - ); - const { - abi: _, - account: __, - address: ___, - ...request - } = { from: writeRequest.account.address, to: writeRequest.address, ...writeRequest }; - scope.setContext("tx", { request }); - const prepared = await startSpan({ name: "prepare transaction", op: "tx.prepare" }, () => - keeper.prepareTransactionRequest({ - to: call.address, - data: encodeFunctionData(call), - ...txOptions, - nonceManager, - }), - ); - scope.setContext("tx", { request, prepared }); - span.setAttribute("tx.nonce", prepared.nonce); - const serializedTransaction = await startSpan({ name: "sign transaction", op: "tx.sign" }, () => - keeper.signTransaction(prepared), - ); - const hash = keccak256(serializedTransaction); - scope.setContext("tx", { request, prepared, hash }); - span.setAttribute("tx.hash", hash); - const abortController = new AbortController(); - const [, receiptResult] = await Promise.allSettled([ - (async () => { - while (!abortController.signal.aborted) { - await Promise.allSettled([ - startSpan({ name: "send transaction", op: "tx.send" }, () => - publicClient.sendRawTransaction({ serializedTransaction }), - ).catch((error: unknown) => { - captureException(error, { level: "error" }); - throw error; - }), - setTimeout(10_000, null, { signal: abortController.signal }), - ]); - } - })(), - startSpan({ name: "wait for receipt", op: "tx.wait" }, () => - publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }), - ) - .catch((error: unknown) => { - if (error instanceof WaitForTransactionReceiptTimeoutError) { - startSpan( - { name: "nonce reset", op: "tx.reset", attributes: { "tx.nonce": prepared.nonce } }, - (resetSpan) => { - const info = nonceManager.info({ address: keeper.account.address, chainId: chain.id }); - resetSpan.setAttribute("exa.reset", true); - resetSpan.setAttribute("exa.delta", info.delta); - resetSpan.setAttribute("exa.nonce", info.nonce); - nonceManager.hardReset({ address: keeper.account.address, chainId: chain.id }); - }, - ); - } - throw error; - }) - .finally(() => { - abortController.abort(); - }), - Promise.resolve(options?.onHash?.(hash)).catch((error: unknown) => - captureException(error, { level: "error" }), - ), - ]); - if (receiptResult.status === "rejected") throw receiptResult.reason; - const receipt = receiptResult.value; - scope.setContext("tx", { request, receipt }); - const [traceResult] = await Promise.allSettled([ - startSpan({ name: "trace transaction", op: "tx.trace" }, () => - withRetry(() => traceClient.traceTransaction(hash), { - delay: 1000, - retryCount: 10, - shouldRetry: ({ error }) => error instanceof InvalidInputRpcError, - }).catch((error: unknown) => { - captureException(error, { level: "error" }); - return null; - }), - ), - Promise.resolve(options?.onReceipt?.(receipt)).catch((error: unknown) => - captureException(error, { level: "error" }), - ), - ]); - const trace = traceResult.status === "fulfilled" ? traceResult.value : null; - scope.setContext("tx", { request, receipt, trace }); - if (receipt.status !== "success") { - if (!trace) throw new Error("no trace"); - // eslint-disable-next-line @typescript-eslint/only-throw-error -- returns error - throw getContractError(new RawContractError({ data: trace.output }), { ...call, args: call.args ?? [] }); - } - span.setStatus({ code: SPAN_STATUS_OK }); - return receipt; - } catch (error: unknown) { - const reason = revertReason(error, { fallback: "message", withArguments: true }); - if (options?.ignore) { - const ignore = - typeof options.ignore === "function" ? await options.ignore(reason) : options.ignore.includes(reason); - if (ignore) { - span.setAttribute("exa.error", reason); - span.setStatus({ code: SPAN_STATUS_OK }); - return ignore === true ? null : ignore; - } - } - span.setStatus({ code: SPAN_STATUS_ERROR, message: reason }); - const level = - typeof options?.level === "function" ? options.level(reason, error) : (options?.level ?? "error"); - if (level) { - withScope((captureScope) => { - const fingerprint = revertFingerprint(error); - if (fingerprint[1] && fingerprint[1] !== "unknown") { - const type = fingerprint.length > 2 ? `${fingerprint[1]}(${fingerprint[2]})` : fingerprint[1]; - captureScope.addEventProcessor((event) => { - if (event.exception?.values?.[0]) event.exception.values[0].type = type; - return event; - }); - } - captureException(error, { level, fingerprint }); - }); - } - throw error; - } - }), - ), - }; + const base = baseExtender(keeper); + return { ...base, poke: async ( diff --git a/server/vitest.config.mts b/server/vitest.config.mts index 9b1893b71..f52f9e363 100644 --- a/server/vitest.config.mts +++ b/server/vitest.config.mts @@ -19,6 +19,10 @@ export default defineConfig({ BRIDGE_API_KEY: "bridge", BRIDGE_API_URL: "https://bridge.test", EXPO_PUBLIC_ALCHEMY_API_KEY: " ", + GCP_BASE64_JSON: "WlhsS01HVllRbXhKYW05blNXNU9iR051V25CWk1sWm1XVmRPYW1JelZuVmtRMG81UTJjOVBRbz0K", + GCP_KMS_KEY_RING: "op-sepolia", + GCP_KMS_KEY_VERSION: "1", + GCP_PROJECT_ID: "exa-dev", INTERCOM_IDENTITY_KEY: "a9cBeTfEtGPSQ58REZP35Bx00ofajvStEc8TTuBtSmk", ISSUER_PRIVATE_KEY: padHex("0x420"), MANTECA_API_URL: "https://manteca.test", From 912115fc3676f3d82c9956ee9c627a7714dc44f3 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 19 Feb 2026 16:18:58 -0300 Subject: [PATCH 47/84] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20server:=20use=20hois?= =?UTF-8?q?ted=20redis=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/test/api/auth.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/test/api/auth.test.ts b/server/test/api/auth.test.ts index de0581c70..dd2b8ee46 100644 --- a/server/test/api/auth.test.ts +++ b/server/test/api/auth.test.ts @@ -21,7 +21,6 @@ import app, { type Authentication } from "../../api/auth/authentication"; import registrationApp from "../../api/auth/registration"; import database, { credentials } from "../../database"; import * as publicClient from "../../utils/publicClient"; -import redis from "../../utils/redis"; import validFactories from "../../utils/validFactories"; import type * as SimpleWebAuthn from "@simplewebauthn/server"; @@ -700,13 +699,15 @@ vi.mock("@simplewebauthn/server", async (importOriginal) => { }; }); -vi.mock("../../utils/redis", () => ({ - default: { - getdel: vi.fn<() => Promise>().mockResolvedValue("test-challenge"), - set: vi.fn<() => Promise>().mockResolvedValue(true), - }, +const redis = vi.hoisted(() => ({ + get: vi.fn<() => Promise>().mockResolvedValue("test-challenge"), + getdel: vi.fn<() => Promise>().mockResolvedValue("test-challenge"), + set: vi.fn<() => Promise>().mockResolvedValue(true), + del: vi.fn<() => Promise>().mockResolvedValue(1), })); +vi.mock("../../utils/redis", () => ({ default: redis, requestRedis: redis })); + vi.mock("@simplewebauthn/server/helpers", async (importOriginal) => { const original = await importOriginal(); return { From 03849cfbcd355c78a9d2cb1cd375f9f5cf9593cd Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 19 Feb 2026 17:25:26 -0300 Subject: [PATCH 48/84] =?UTF-8?q?=F0=9F=A6=BA=20server:=20make=20auth=20qu?= =?UTF-8?q?ery=20param=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/auth/authentication.ts | 10 ++++++---- server/api/auth/registration.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/server/api/auth/authentication.ts b/server/api/auth/authentication.ts index f5d288a7f..330f03a89 100644 --- a/server/api/auth/authentication.ts +++ b/server/api/auth/authentication.ts @@ -253,9 +253,11 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se vValidator("header", optional(object({ "Client-Fid": optional(pipe(string(), maxLength(36))) }))), vValidator( "query", - object({ - factory: optional(pipe(Address, title("Factory"), description("Account factory address."))), - }), + optional( + object({ + factory: optional(pipe(Address, title("Factory"), description("Account factory address."))), + }), + ), validatorHook({ code: "bad factory" }), ), vValidator( @@ -312,7 +314,7 @@ Submit the signed SIWE message to prove ownership of an Ethereum address. The se ), async (c) => { const assertion = c.req.valid("json"); - const { factory } = c.req.valid("query"); + const factory = c.req.valid("query")?.factory ?? undefined; setContext("auth", assertion); const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); diff --git a/server/api/auth/registration.ts b/server/api/auth/registration.ts index 77e931c89..a52d24a87 100644 --- a/server/api/auth/registration.ts +++ b/server/api/auth/registration.ts @@ -258,9 +258,11 @@ export default new Hono() vValidator("header", optional(object({ "Client-Fid": optional(pipe(string(), maxLength(36))) }))), vValidator( "query", - object({ - factory: optional(pipe(Address, title("Factory"), description("Account factory address."))), - }), + optional( + object({ + factory: optional(pipe(Address, title("Factory"), description("Account factory address."))), + }), + ), validatorHook({ code: "bad factory" }), ), vValidator( @@ -312,7 +314,7 @@ export default new Hono() ), async (c) => { const attestation = c.req.valid("json"); - const { factory } = c.req.valid("query"); + const factory = c.req.valid("query")?.factory ?? undefined; setContext("auth", attestation); const sessionId = c.req.header("x-session-id") ?? c.req.valid("cookie").session_id; if (!sessionId) return c.json({ code: "bad session" }, 400); From ae7ad302f430375591aad39bf17dc2fc9bce750a Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Fri, 20 Feb 2026 14:54:22 -0300 Subject: [PATCH 49/84] =?UTF-8?q?=F0=9F=A4=A1=20server:=20mock=20allower?= =?UTF-8?q?=20for=20e2e=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/test/e2e.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/test/e2e.ts b/server/test/e2e.ts index c0e0ffc92..b5df2e44e 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -180,6 +180,10 @@ vi.mock("../utils/persona", async (importOriginal: () => Promise }; }); +vi.mock("../utils/allower", () => ({ + default: vi.fn(() => Promise.resolve({ allow: vi.fn().mockResolvedValue({}) })), +})); + vi.mock("@sentry/node", async (importOriginal) => { const { captureException, ...original } = await importOriginal(); return { From 8d6fe7c4b8d26f831986191d5417d654c2abae3a Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Wed, 25 Feb 2026 22:09:52 -0300 Subject: [PATCH 50/84] =?UTF-8?q?=F0=9F=92=9A=20github:=20extend=20e2e=20s?= =?UTF-8?q?tartup=20wait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d74d38078..613868a75 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -103,9 +103,9 @@ jobs: pnpm nx run-many -t build -p mobile e2e pnpm nx generate:app server pnpm nx e2e server > server.log 2>&1 & - for i in {1..180}; do + for i in {1..420}; do curl -f http://localhost:3000/api/auth/authentication > /dev/null 2>&1 && break - [ $i -eq 180 ] && cat server.log && exit 1 + [ $i -eq 420 ] && cat server.log && exit 1 sleep 1 done CHROME_LOG_FILE=$PWD/chrome.log xvfb-run maestro test .maestro/flows/web.yaml -e "APP_ID=http://localhost:3000" --format junit --output .maestro/junit.xml From af737a756c9632f4066ddabbcde030746f2e9c39 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Thu, 26 Feb 2026 10:48:18 -0300 Subject: [PATCH 51/84] =?UTF-8?q?=F0=9F=91=B7=20github:=20serve=20e2e=20fr?= =?UTF-8?q?ontend=20on=20expo=20standard=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 613868a75..572ecbe38 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -103,12 +103,13 @@ jobs: pnpm nx run-many -t build -p mobile e2e pnpm nx generate:app server pnpm nx e2e server > server.log 2>&1 & + pnpm dlx serve server/app -l 8081 -s & for i in {1..420}; do curl -f http://localhost:3000/api/auth/authentication > /dev/null 2>&1 && break [ $i -eq 420 ] && cat server.log && exit 1 sleep 1 done - CHROME_LOG_FILE=$PWD/chrome.log xvfb-run maestro test .maestro/flows/web.yaml -e "APP_ID=http://localhost:3000" --format junit --output .maestro/junit.xml + CHROME_LOG_FILE=$PWD/chrome.log xvfb-run maestro test .maestro/flows/web.yaml --format junit --output .maestro/junit.xml env: { APP_DOMAIN: localhost, EXPO_PUBLIC_ENV: e2e } - if: always() run: | From ae5aa906f8e4e6965bd8eaceab907dae5a3c4131 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Fri, 30 Jan 2026 14:51:17 -0300 Subject: [PATCH 52/84] =?UTF-8?q?=F0=9F=94=A7=20common:=20setup=20all=20on?= =?UTF-8?q?esignal=20domains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/four-canyons-mix.md | 5 +++++ common/onesignalAppId.web.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) create mode 100644 .changeset/four-canyons-mix.md create mode 100644 common/onesignalAppId.web.ts diff --git a/.changeset/four-canyons-mix.md b/.changeset/four-canyons-mix.md new file mode 100644 index 000000000..314483578 --- /dev/null +++ b/.changeset/four-canyons-mix.md @@ -0,0 +1,5 @@ +--- +"@exactly/common": patch +--- + +🔧 setup all onesignal domains diff --git a/common/onesignalAppId.web.ts b/common/onesignalAppId.web.ts new file mode 100644 index 000000000..f47a99a92 --- /dev/null +++ b/common/onesignalAppId.web.ts @@ -0,0 +1,10 @@ +import domain from "./domain"; + +export default (process.env.EXPO_PUBLIC_ONESIGNAL_APP_ID || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- ignore empty string + { + "web.exactly.app": "31d4be98-1fa3-4a8c-9657-dc21c991adc7", + "base.exactly.app": "9f896065-637d-455c-baff-4041268dafce", + "sandbox.exactly.app": "15bd3cf9-f71e-43f2-96ff-e76916a832a3", + "base-sepolia.exactly.app": "893d33c6-d1bd-46cb-9047-d4d524f384f0", + }[domain]) ?? + "2f79a35c-8b11-4725-84d8-fc096f3f216e"; From 0d9ef7e588007602a3e8b297bd7f5ea990dfd113 Mon Sep 17 00:00:00 2001 From: mainqueg Date: Mon, 2 Mar 2026 17:18:22 -0300 Subject: [PATCH 53/84] =?UTF-8?q?=F0=9F=9A=A9=20server:=20remove=20base=20?= =?UTF-8?q?from=20supported=20ramp=20chains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/test/utils/manteca.test.ts | 10 +++++----- server/test/utils/persona.test.ts | 4 ++-- server/utils/ramps/bridge.ts | 4 +--- server/utils/ramps/manteca.ts | 6 ++---- server/utils/ramps/shared.ts | 6 +++--- 5 files changed, 13 insertions(+), 17 deletions(-) diff --git a/server/test/utils/manteca.test.ts b/server/test/utils/manteca.test.ts index 256ae36de..b90a9e612 100644 --- a/server/test/utils/manteca.test.ts +++ b/server/test/utils/manteca.test.ts @@ -2,7 +2,7 @@ import "../mocks/sentry"; import { parse } from "valibot"; import { padHex } from "viem"; -import { baseSepolia, optimism } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Address } from "@exactly/common/validation"; @@ -264,8 +264,8 @@ describe("manteca utils", () => { await expect(manteca.withdrawBalance("456", "USDC", address)).rejects.toThrow(ErrorCodes.NOT_SUPPORTED_CHAIN_ID); }); - it("withdraws with BASE network on development chain", async () => { - chainMock.id = baseSepolia.id; + it("withdraws with OPTIMISM network on development chain", async () => { + chainMock.id = optimismSepolia.id; const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce(mockFetchResponse({ ...mockBalanceBase, balance: { USDC: "100.00" } })) @@ -276,7 +276,7 @@ describe("manteca utils", () => { const withdrawCall = fetchSpy.mock.calls[1]; const body = JSON.parse(withdrawCall?.[1]?.body as string) as Record; expect(body).toMatchObject({ - destination: { address, network: "BASE" }, + destination: { address, network: "OPTIMISM" }, }); }); }); @@ -414,7 +414,7 @@ describe("manteca utils", () => { }); it("returns currencies on development chain", async () => { - chainMock.id = baseSepolia.id; + chainMock.id = optimismSepolia.id; vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: false, status: 404, diff --git a/server/test/utils/persona.test.ts b/server/test/utils/persona.test.ts index c8e27db73..bc58ba899 100644 --- a/server/test/utils/persona.test.ts +++ b/server/test/utils/persona.test.ts @@ -2,7 +2,7 @@ import "../mocks/persona"; import "../mocks/sentry"; import { array, minLength, number, object, optional, pipe, safeParse, string, union } from "valibot"; -import { baseSepolia, optimism } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import * as persona from "../../utils/persona"; @@ -577,7 +577,7 @@ describe("evaluateAccount", () => { describe("getAllowedMantecaIds", () => { describe("development mode", () => { beforeEach(() => { - chainMock.id = baseSepolia.id; + chainMock.id = optimismSepolia.id; }); it("returns allowed ids for supported countries (AR)", () => { diff --git a/server/utils/ramps/bridge.ts b/server/utils/ramps/bridge.ts index 42b38682c..35478d410 100644 --- a/server/utils/ramps/bridge.ts +++ b/server/utils/ramps/bridge.ts @@ -19,7 +19,7 @@ import { type InferInput, type InferOutput, } from "valibot"; -import { base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; import chain from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; @@ -283,8 +283,6 @@ const SupportedOnRampChainId: Record< (typeof CryptoPaymentRail)[number] | undefined > = { [optimism.id]: "optimism", - [base.id]: "base", - [baseSepolia.id]: "base", [optimismSepolia.id]: "optimism", } as const; diff --git a/server/utils/ramps/manteca.ts b/server/utils/ramps/manteca.ts index 1b0c86fcf..84a34cbdb 100644 --- a/server/utils/ramps/manteca.ts +++ b/server/utils/ramps/manteca.ts @@ -15,7 +15,7 @@ import { type InferOutput, } from "valibot"; import { withRetry } from "viem"; -import { base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; import chain from "@exactly/common/generated/chain"; import { Address } from "@exactly/common/validation"; @@ -376,13 +376,11 @@ export async function mantecaOnboarding(account: Address, credentialId: string) // #endregion services // #region schemas -const Networks = ["OPTIMISM", "BASE"] as const; +const Networks = ["OPTIMISM"] as const; const SupportedOnRampChainId: Record<(typeof shared.SupportedChainId)[number], (typeof Networks)[number] | undefined> = { [optimism.id]: "OPTIMISM", - [base.id]: "BASE", - [baseSepolia.id]: "BASE", [optimismSepolia.id]: "OPTIMISM", } as const; diff --git a/server/utils/ramps/shared.ts b/server/utils/ramps/shared.ts index 81d5b1e6b..cfee663ff 100644 --- a/server/utils/ramps/shared.ts +++ b/server/utils/ramps/shared.ts @@ -1,12 +1,12 @@ import { array, literal, object, optional, picklist, string, variant } from "valibot"; -import { base, baseSepolia, optimism, optimismSepolia } from "viem/chains"; +import { optimism, optimismSepolia } from "viem/chains"; export const Currency = ["ARS", "USD", "CLP", "BRL", "COP", "PUSD", "CRC", "GTQ", "MXN", "PHP", "BOB", "EUR"] as const; export const Cryptocurrency = ["USDC", "USDT", "ETH", "SOL", "BTC", "DAI", "PYUSD", "USDP"] as const; // cspell:ignore usdp export const RampProvider = ["manteca", "bridge"] as const; -export const SupportedChainId = [optimism.id, base.id, baseSepolia.id, optimismSepolia.id] as const; -export const DevelopmentChainIds = [baseSepolia.id, optimismSepolia.id] as const; +export const SupportedChainId = [optimism.id, optimismSepolia.id] as const; +export const DevelopmentChainIds = [optimismSepolia.id] as const; export const FiatNetwork = [ "ARG_FIAT_TRANSFER", From 2ce9d9034a0ad0e61fd7cb88761f1fe97c36aa08 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Mon, 2 Mar 2026 16:30:56 -0300 Subject: [PATCH 54/84] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20webhook=20?= =?UTF-8?q?logging=20for=20text=20response?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fifty-friends-bet.md | 5 +++ server/hooks/panda.ts | 16 +++++++- server/test/hooks/panda.test.ts | 71 ++++++++++++++++++++++++++++----- 3 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 .changeset/fifty-friends-bet.md diff --git a/.changeset/fifty-friends-bet.md b/.changeset/fifty-friends-bet.md new file mode 100644 index 000000000..b3d74ac32 --- /dev/null +++ b/.changeset/fifty-friends-bet.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix webhook logging for text response diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 244c4144e..e09c00795 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1216,7 +1216,13 @@ async function publish(payload: v.InferOutput, receipt?: Transac throw new Error("WebhookFailed", { cause: { code: response.status, - response: await response.json(), + response: await response.text().then((text) => { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + }), payload: webhookPayload, }, }); @@ -1235,7 +1241,13 @@ async function publish(payload: v.InferOutput, receipt?: Transac ); debugWebhook({ code: result.status, - response: await result.json(), + response: await result.text().then((text) => { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + }), payload: webhookPayload, }); } catch (error) { diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 75373fff7..9b8fc9415 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2173,7 +2173,7 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { if (url === "https://exa.test") { publish = true; - return { ok: true, status: 200 } as Response; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; } return fetch(url, init); }); @@ -2211,7 +2211,7 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { if (url === "https://exa.test") { publish = true; - return { ok: true, status: 200 } as Response; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; } return fetch(url, init); }); @@ -2251,7 +2251,7 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { if (url === "https://exa.test") { publishCounter++; - return { ok: true, status: 200 } as Response; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; } return fetch(url, init); }); @@ -2304,8 +2304,8 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, - json() { - return Promise.resolve({}); + text() { + return Promise.resolve("{}"); }, } as Response); @@ -2332,8 +2332,8 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, - json() { - return Promise.resolve({}); + text() { + return Promise.resolve("{}"); }, } as Response); @@ -2359,8 +2359,8 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, - json() { - return Promise.resolve({}); + text() { + return Promise.resolve("{}"); }, } as Response); @@ -2381,6 +2381,52 @@ describe("webhooks", () => { expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); + + it("logs text on webhook ok response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve("OK"), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); + expect(webhookLogger).toHaveBeenCalledWith(expect.objectContaining({ response: "OK" })); + }); + + it("logs json on webhook ok response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ status: 200, message: "OK" })), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); + expect(webhookLogger).toHaveBeenCalledWith(expect.objectContaining({ response: { status: 200, message: "OK" } })); + }); }); const authorization = { @@ -2694,6 +2740,13 @@ const userResponseTemplate = { vi.mock("@sentry/node", { spy: true }); +const webhookLogger = vi.hoisted(() => vi.fn()); + +vi.mock("debug", () => { + const createDebug = vi.fn().mockReturnValueOnce(vi.fn()).mockReturnValueOnce(webhookLogger); + return { default: createDebug }; +}); + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); From 1c1ce7e7f20d2fed1d6ec384bac3fc4b015b2fbc Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Mon, 2 Mar 2026 16:51:41 -0300 Subject: [PATCH 55/84] =?UTF-8?q?=F0=9F=A5=85=20server:=20fix=20pax=20capt?= =?UTF-8?q?ure=20exception=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/hooks/persona.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/hooks/persona.ts b/server/hooks/persona.ts index 22fa4aa11..507e3fc52 100644 --- a/server/hooks/persona.ts +++ b/server/hooks/persona.ts @@ -320,7 +320,7 @@ export default new Hono().post( phone: fields.phoneNumber?.value ?? "", internalId: deriveAssociateId(account), product: "travel insurance", - }).catch((error: unknown) => captureException(error, { level: "error", extra: { pandaId: id, referenceId } })); + }).catch((error: unknown) => captureException(error, { level: "error" })); // TODO implement error handling to return 200 if event should not be retried const { id } = await createUser({ From c3981755bcd74236b657d4e91cade928aaa9e7c6 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 3 Mar 2026 10:05:28 -0300 Subject: [PATCH 56/84] =?UTF-8?q?=F0=9F=A5=85=20server:=20retry=20trace=20?= =?UTF-8?q?on=20resource=20not=20found?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/bright-eagle-catch.md | 5 +++ server/test/utils/keeper.test.ts | 73 +++++++++++++++++++++++++++++++- server/utils/baseExtender.ts | 4 +- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 .changeset/bright-eagle-catch.md diff --git a/.changeset/bright-eagle-catch.md b/.changeset/bright-eagle-catch.md new file mode 100644 index 000000000..847c26b46 --- /dev/null +++ b/.changeset/bright-eagle-catch.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🥅 retry trace on resource not found diff --git a/server/test/utils/keeper.test.ts b/server/test/utils/keeper.test.ts index cc4e0d571..4109b3d3b 100644 --- a/server/test/utils/keeper.test.ts +++ b/server/test/utils/keeper.test.ts @@ -4,7 +4,14 @@ import "../mocks/sentry"; import { captureException } from "@sentry/node"; import { setImmediate } from "node:timers/promises"; -import { encodeErrorResult, getContractError, RawContractError } from "viem"; +import { + encodeErrorResult, + getContractError, + HttpRequestError, + InvalidInputRpcError, + RawContractError, + ResourceNotFoundRpcError, +} from "viem"; import { afterEach, describe, expect, inject, it, vi } from "vitest"; import { auditorAbi } from "@exactly/common/generated/chain"; @@ -12,6 +19,7 @@ import { auditorAbi } from "@exactly/common/generated/chain"; import keeper from "../../utils/keeper"; import nonceManager from "../../utils/nonceManager"; import publicClient from "../../utils/publicClient"; +import traceClient from "../../utils/traceClient"; import type { Hash, Hex } from "@exactly/common/validation"; import type * as timers from "node:timers/promises"; @@ -308,6 +316,69 @@ describe("level option", () => { }); }); +describe("trace transaction retry", () => { + it("retries on ResourceNotFoundRpcError", async () => { + const traceTransaction = vi.spyOn(traceClient, "traceTransaction"); + traceTransaction + .mockRejectedValueOnce(new ResourceNotFoundRpcError(new HttpRequestError({ body: {}, url: "" }))) + .mockResolvedValueOnce({ + from: "0x", + gas: "0x0", + gasUsed: "0x0", + input: "0x", + output: "0x", + to: "0x", + type: "CALL", + }); + const receipt = await keeper.exaSend( + { name: "test transfer", op: "test.transfer" }, + { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, + ); + + expect(receipt?.status).toBe("success"); + expect(traceTransaction).toHaveBeenCalledTimes(2); + }); + + it("retries on InvalidInputRpcError", async () => { + const traceTransaction = vi.spyOn(traceClient, "traceTransaction"); + traceTransaction + .mockRejectedValueOnce(new InvalidInputRpcError(new HttpRequestError({ body: {}, url: "" }))) + .mockResolvedValueOnce({ + from: "0x", + gas: "0x0", + gasUsed: "0x0", + input: "0x", + output: "0x", + to: "0x", + type: "CALL", + }); + const receipt = await keeper.exaSend( + { name: "test transfer", op: "test.transfer" }, + { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, + ); + + expect(receipt?.status).toBe("success"); + expect(traceTransaction).toHaveBeenCalledTimes(2); + }); + + it("captures exception when trace fails with non-retryable error", async () => { + const traceTransaction = vi.spyOn(traceClient, "traceTransaction"); + traceTransaction.mockRejectedValue(new Error("debug_traceTransaction unavailable")); + const initialCalls = vi.mocked(captureException).mock.calls.length; + const receipt = await keeper.exaSend( + { name: "test transfer", op: "test.transfer" }, + { address: inject("Auditor"), abi: auditorAbi, functionName: "enterMarket", args: [inject("MarketUSDC")] }, + ); + + expect(receipt?.status).toBe("success"); + const calls = vi.mocked(captureException).mock.calls.slice(initialCalls); + expect(calls).toContainEqual([ + expect.objectContaining({ message: "debug_traceTransaction unavailable" }), + expect.objectContaining({ level: "error" }), + ]); + }); +}); + vi.mock("@sentry/node", { spy: true }); vi.mock("node:timers/promises", async (importOriginal) => { const original = await importOriginal(); diff --git a/server/utils/baseExtender.ts b/server/utils/baseExtender.ts index 3b85898c6..946b0ba44 100644 --- a/server/utils/baseExtender.ts +++ b/server/utils/baseExtender.ts @@ -7,6 +7,7 @@ import { InvalidInputRpcError, keccak256, RawContractError, + ResourceNotFoundRpcError, WaitForTransactionReceiptTimeoutError, withRetry, type HttpTransport, @@ -134,7 +135,8 @@ export default function baseExtender( withRetry(() => traceClient.traceTransaction(hash), { delay: 1000, retryCount: 10, - shouldRetry: ({ error }) => error instanceof InvalidInputRpcError, + shouldRetry: ({ error }) => + error instanceof InvalidInputRpcError || error instanceof ResourceNotFoundRpcError, }).catch((error: unknown) => { captureException(error, { level: "error" }); return null; From 78cdc72886babc02b197a0dd674f1a58725ebb7b Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Tue, 3 Mar 2026 10:32:14 -0300 Subject: [PATCH 57/84] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20server:=20retry=20po?= =?UTF-8?q?ke=20calls=20on=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/utils/keeper.ts | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts index b0aa87dba..30350d169 100644 --- a/server/utils/keeper.ts +++ b/server/utils/keeper.ts @@ -4,6 +4,7 @@ import { createWalletClient, erc20Abi, http, + withRetry, type HttpTransport, type PrivateKeyAccount, type WalletClient, @@ -100,25 +101,32 @@ export function extender(keeper: WalletClient - base.exaSend( + withRetry( + () => + base.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 }] : []), + ), { - name: "poke account", - op: "exa.poke", - attributes: { account: accountAddress, asset }, + retryCount: 10, + delay: ({ count }) => Math.trunc(1 << count) * 60, }, - asset === ETH - ? { - address: accountAddress, - abi: combinedAccountAbi, - functionName: "pokeETH", - } - : { - address: accountAddress, - abi: combinedAccountAbi, - functionName: "poke", - args: [market], - }, - ...(options?.ignore ? [{ ignore: options.ignore }] : []), ), ), ).then((r) => { From 2190047712fe845398374f12c0a30c2d51f07464 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 3 Mar 2026 15:57:35 -0300 Subject: [PATCH 58/84] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20onesignal?= =?UTF-8?q?=20app=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/utils/onesignal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/onesignal.ts b/server/utils/onesignal.ts index b84868d15..8a58d0786 100644 --- a/server/utils/onesignal.ts +++ b/server/utils/onesignal.ts @@ -1,6 +1,6 @@ import { createConfiguration, DefaultApi, Notification } from "@onesignal/node-onesignal"; -import appId from "@exactly/common/onesignalAppId"; +import appId from "@exactly/common/onesignalAppId.web"; const client = new DefaultApi(createConfiguration({ restApiKey: process.env.ONESIGNAL_API_KEY })); From 40ce327b11d2dfc286219ce448e11c1eab37a380 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Fri, 6 Mar 2026 11:34:11 -0300 Subject: [PATCH 59/84] =?UTF-8?q?=F0=9F=94=A7=20server:=20set=20farcaster?= =?UTF-8?q?=20manifest=20signature=20for=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/index.ts b/server/index.ts index 88a3aabe0..2f216bdbe 100644 --- a/server/index.ts +++ b/server/index.ts @@ -78,6 +78,7 @@ app.get("/.well-known/farcaster.json", (c) => ), payload: isoBase64URL.fromUTF8String(`{"domain":"${domain}"}`), signature: { + "base.exactly.app": "0lyEaPuI4Z8Nrc1Yq9rqdPbsimYLnOzMYwv8z8GjwgUCzm4bGx/C1KylzUafPqS9bmtL/iaj0eNU1+MFZUXRGRs=", "web.exactly.app": "MHg1NDJkZTQ0ZGNkOThlMTBmMGI4NWMwY2I4YjU0ODliNTBlYWViYWY2YzE1YTk3NGVkNzk4NTY4ZmE2NDhiY2M2MDhlNWQ4NzliYTQ5M2E3NjhiMmQzYmM0YWZkN2U0ODNkMjQ1MDkxM2RjZDdlNTIzZWRhMzRkN2VlYjc0NmQ3ZjFi", "sandbox.exactly.app": From 40fe324141c29f8be784f5c590a3ef624df8ba4e Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Fri, 6 Mar 2026 16:13:38 -0300 Subject: [PATCH 60/84] =?UTF-8?q?=F0=9F=A5=85=20app:=20surface=20bridge=20?= =?UTF-8?q?failures=20in=20sentry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/add-funds/Bridge.tsx | 32 +++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index fcba9e221..f65425767 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -33,7 +33,7 @@ import AssetSelectSheet from "./AssetSelectSheet"; import { getBridgeSources, getRouteFrom, tokenCorrelation, type BridgeSources, type RouteFrom } from "../../utils/lifi"; import openBrowser from "../../utils/openBrowser"; import queryClient from "../../utils/queryClient"; -import reportError from "../../utils/reportError"; +import reportError, { isPasskeyCancelled } from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; import ownerConfig from "../../utils/wagmi/owner"; import AssetLogo from "../shared/AssetLogo"; @@ -221,7 +221,7 @@ export default function Bridge() { sourceAmount, isSameChain, ], - queryFn: () => { + queryFn: async () => { if ( !senderAddress || !account || @@ -232,15 +232,20 @@ export default function Bridge() { isSameChain ) throw new Error("invalid bridge parameters"); - return getRouteFrom({ - fromChainId: effectiveSource.chain, - toChainId: chain.id, - fromTokenAddress: sourceToken.address, - toTokenAddress: destinationToken.address, - fromAmount: sourceAmount, - fromAddress: senderAddress, - toAddress: account, - }); + try { + return await getRouteFrom({ + fromChainId: effectiveSource.chain, + toChainId: chain.id, + fromTokenAddress: sourceToken.address, + toTokenAddress: destinationToken.address, + fromAmount: sourceAmount, + fromAddress: senderAddress, + toAddress: account, + }); + } catch (error) { + reportError(error, { level: "warning" }); + throw error; + } }, enabled: bridgeQuoteEnabled, refetchInterval: 15_000, @@ -271,6 +276,10 @@ export default function Bridge() { query: { enabled: transferSimulationEnabled }, }); + useEffect(() => { + if (transferSimulationError) reportError(transferSimulationError, { level: "warning" }); + }, [transferSimulationError]); + const approvalTokenAddress = effectiveSource?.address && isAddress(effectiveSource.address) ? effectiveSource.address : undefined; const approvalSpenderAddress = bridgeQuote?.estimate.approvalAddress; @@ -988,6 +997,7 @@ export default function Bridge() { function handleError(error: unknown, toast: ReturnType, t: TFunction, isTransfer?: boolean) { if (error instanceof UserRejectedRequestError) return; if (error instanceof TransactionExecutionError && error.shortMessage === "User rejected the request.") return; + if (isPasskeyCancelled(error)) return; toast.show(isTransfer ? t("Transfer failed. Please try again.") : t("Bridge failed. Please try again."), { native: true, duration: 1000, From f362f367895acee1336a86a4c2dd47022ad46954 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Wed, 4 Mar 2026 10:50:31 -0300 Subject: [PATCH 61/84] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20dependencies:=20capt?= =?UTF-8?q?ure=20rejection=20cause=20in=20farcaster=20sdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + patches/@farcaster__miniapp-sdk.patch | 68 +++++++++++++++++++++++++++ pnpm-lock.yaml | 15 +++--- src/utils/accountClient.ts | 10 ++-- src/utils/reportError.ts | 11 +++++ 5 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 patches/@farcaster__miniapp-sdk.patch diff --git a/package.json b/package.json index 27507337c..27da0324f 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ "solidity-coverage": "npm:@favware/skip-dependency@1.2.2" }, "patchedDependencies": { + "@farcaster/miniapp-sdk": "patches/@farcaster__miniapp-sdk.patch", "@lifi/sdk": "patches/@lifi__sdk.patch", "embedded-postgres": "patches/embedded-postgres.patch", "eslint-config-universe": "patches/eslint-config-universe.patch", diff --git a/patches/@farcaster__miniapp-sdk.patch b/patches/@farcaster__miniapp-sdk.patch new file mode 100644 index 000000000..a6c1bac3b --- /dev/null +++ b/patches/@farcaster__miniapp-sdk.patch @@ -0,0 +1,68 @@ +diff --git a/dist/ethereumProvider.js b/dist/ethereumProvider.js +index a610b1ca8df86c4e156cdb3a925bcf25fee0bd3b..781b021d6b99a7f8d8b04d8d14d9b8dd22f02c5d 100644 +--- a/dist/ethereumProvider.js ++++ b/dist/ethereumProvider.js +@@ -4,21 +4,32 @@ import * as RpcResponse from 'ox/RpcResponse'; + import { miniAppHost } from "./miniAppHost.js"; + const emitter = Provider.createEmitter(); + const store = RpcRequest.createStore(); +-function toProviderRpcError({ code, details, }) { ++const replacer = (_, v) => typeof v === 'bigint' ? `0x${v.toString(16)}` : v; ++function toProviderRpcError(error, context) { ++ const { code, details } = error; ++ let e; + switch (code) { + case 4001: +- return new Provider.UserRejectedRequestError(); ++ e = new Provider.UserRejectedRequestError({ message: details }); ++ break; + case 4100: +- return new Provider.UnauthorizedError(); ++ e = new Provider.UnauthorizedError(); ++ break; + case 4200: +- return new Provider.UnsupportedMethodError(); ++ e = new Provider.UnsupportedMethodError(); ++ break; + case 4900: +- return new Provider.DisconnectedError(); ++ e = new Provider.DisconnectedError(); ++ break; + case 4901: +- return new Provider.ChainDisconnectedError(); ++ e = new Provider.ChainDisconnectedError(); ++ break; + default: +- return new Provider.ProviderRpcError(code, details ?? 'Unknown provider RPC error'); ++ e = new Provider.ProviderRpcError(code, details ?? 'Unknown provider RPC error'); ++ break; + } ++ try { e.cause = new Error(JSON.stringify(context, replacer)); } catch {} ++ return e; + } + export const ethereumProvider = Provider.from({ + ...emitter, +@@ -26,11 +37,11 @@ export const ethereumProvider = Provider.from({ + // @ts-expect-error + const request = store.prepare(args); + try { +- const response = await miniAppHost +- .ethProviderRequestV2(request) +- .then((res) => RpcResponse.parse(res, { request, raw: true })); ++ const raw = await miniAppHost.ethProviderRequestV2(request); ++ const response = RpcResponse.parse(raw, { request, raw: true }); + if (response.error) { +- throw toProviderRpcError(response.error); ++ globalThis.__captureProviderError__?.(args.method, { request: args, raw }); ++ throw toProviderRpcError(response.error, { request: args, raw }); + } + return response.result; + } +@@ -44,6 +55,7 @@ export const ethereumProvider = Provider.from({ + e instanceof RpcResponse.BaseError) { + throw e; + } ++ globalThis.__captureProviderError__?.(args.method, { request: args, error: e instanceof Error ? { message: e.message, name: e.name, stack: e.stack } : e }); + throw new RpcResponse.InternalError({ + message: e instanceof Error ? e.message : undefined, + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83eca9e52..f6229620e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ overrides: solidity-coverage: npm:@favware/skip-dependency@1.2.2 patchedDependencies: + '@farcaster/miniapp-sdk': + hash: c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e + path: patches/@farcaster__miniapp-sdk.patch '@lifi/sdk': hash: ee16233f297d9a6c8a8320b5dc2b4bf47b7be8b481d79e3d54108cd77775b45b path: patches/@lifi__sdk.patch @@ -135,10 +138,10 @@ importers: version: 0.1.12(typescript@5.9.3)(zod@4.3.5) '@farcaster/miniapp-sdk': specifier: ^0.2.1 - version: 0.2.1(typescript@5.9.3)(zod@4.3.5) + version: 0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5) '@farcaster/miniapp-wagmi-connector': specifier: ^1.1.0 - version: 1.1.0(@farcaster/miniapp-sdk@0.2.1(typescript@5.9.3)(zod@4.3.5))(@wagmi/core@3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) + version: 1.1.0(@farcaster/miniapp-sdk@0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5))(@wagmi/core@3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) '@intercom/intercom-react-native': specifier: ^9.4.0 version: 9.4.0(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(bufferutil@4.1.0)(react@19.1.0)(utf-8-validate@5.0.10))(react@19.1.0) @@ -16627,7 +16630,7 @@ snapshots: '@farcaster/frame-sdk@0.1.12(typescript@5.9.3)(zod@4.3.5)': dependencies: - '@farcaster/miniapp-sdk': 0.2.1(typescript@5.9.3)(zod@4.3.5) + '@farcaster/miniapp-sdk': 0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5) '@farcaster/quick-auth': 0.0.8(typescript@5.9.3) comlink: 4.4.2 eventemitter3: 5.0.4 @@ -16644,7 +16647,7 @@ snapshots: transitivePeerDependencies: - typescript - '@farcaster/miniapp-sdk@0.2.1(typescript@5.9.3)(zod@4.3.5)': + '@farcaster/miniapp-sdk@0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5)': dependencies: '@farcaster/miniapp-core': 0.4.1(typescript@5.9.3) '@farcaster/quick-auth': 0.0.6(typescript@5.9.3) @@ -16655,9 +16658,9 @@ snapshots: - typescript - zod - '@farcaster/miniapp-wagmi-connector@1.1.0(@farcaster/miniapp-sdk@0.2.1(typescript@5.9.3)(zod@4.3.5))(@wagmi/core@3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5))': + '@farcaster/miniapp-wagmi-connector@1.1.0(@farcaster/miniapp-sdk@0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5))(@wagmi/core@3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5))': dependencies: - '@farcaster/miniapp-sdk': 0.2.1(typescript@5.9.3)(zod@4.3.5) + '@farcaster/miniapp-sdk': 0.2.1(patch_hash=c779770b62e09976bdb62d23f50ed7922449031159c67766586c0e76f41acb7e)(typescript@5.9.3)(zod@4.3.5) '@wagmi/core': 3.2.2(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.11.3(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) viem: 2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5) diff --git a/src/utils/accountClient.ts b/src/utils/accountClient.ts index fcec943b8..adda99fb8 100644 --- a/src/utils/accountClient.ts +++ b/src/utils/accountClient.ts @@ -61,7 +61,7 @@ import e2e from "./e2e"; import { login } from "./onesignal"; import publicClient from "./publicClient"; import queryClient, { type AuthMethod } from "./queryClient"; -import { isPasskeyCancelled } from "./reportError"; +import reportError, { isPasskeyCancelled } from "./reportError"; import ownerConfig from "./wagmi/owner"; import type { Credential } from "@exactly/common/validation"; @@ -180,8 +180,8 @@ export default async function createAccountClient({ credentialId, factory, x, y }, }, }); - } catch { - // TODO filter errors + } catch (error) { + reportError(error, { level: "warning" }); const hash = await sendTransaction(ownerConfig, execute); return { id: concat([hash, numberToHex(chain.id, { size: 32 }), TX_MAGIC_ID]) }; } @@ -225,8 +225,8 @@ export default async function createAccountClient({ credentialId, factory, x, y }, }); return id; - } catch { - // TODO filter errors + } catch (error) { + reportError(error, { level: "warning" }); return client.request({ method: method as never, params: params as never }); } } diff --git a/src/utils/reportError.ts b/src/utils/reportError.ts index 71a06d7a2..e45058c17 100644 --- a/src/utils/reportError.ts +++ b/src/utils/reportError.ts @@ -3,6 +3,17 @@ import { BaseError, ContractFunctionRevertedError } from "viem"; import revertReason from "@exactly/common/revertReason"; +const bigintReplacer = (_: string, v: unknown) => (typeof v === "bigint" ? `0x${v.toString(16)}` : v); + +(globalThis as Record).__captureProviderError__ = (method: string, data: unknown) => + withScope((scope) => { + scope.setTag("provider.method", method); + scope.setLevel("warning"); + captureException(new Error(`[farcaster-provider] ${method}`), { + extra: { providerError: JSON.stringify(data, bigintReplacer) }, + }); + }); + export default function reportError(error: unknown, hint?: Parameters[1]) { console.error(error); // eslint-disable-line no-console const parsed = parseError(error); From 1ca6e192781c8f73612fb4c782a6567a0f809605 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Fri, 6 Mar 2026 17:22:21 -0300 Subject: [PATCH 62/84] =?UTF-8?q?=F0=9F=90=9B=20app:=20fix=20bridge=20simu?= =?UTF-8?q?lation=20falling=20back=20to=20default=20account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/gold-cow-eat.md | 5 +++++ src/components/add-funds/Bridge.tsx | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/gold-cow-eat.md diff --git a/.changeset/gold-cow-eat.md b/.changeset/gold-cow-eat.md new file mode 100644 index 000000000..f2fc6e06c --- /dev/null +++ b/.changeset/gold-cow-eat.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix bridge simulation falling back to default account diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index f65425767..cf26b5052 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -268,6 +268,7 @@ export default function Bridge() { isPending: isSimulatingTransfer, } = useSimulateContract({ config: senderConfig, + account: senderAddress, chainId: transferSimulationEnabled ? effectiveSource.chain : undefined, address: transferSimulationEnabled ? getAddress(effectiveSource.address) : undefined, abi: erc20Abi, From 8d5c63aebf5609da5d7a83d2988f0063e1efeba5 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Tue, 10 Mar 2026 15:17:23 -0300 Subject: [PATCH 63/84] =?UTF-8?q?=F0=9F=90=9B=20app:=20fix=20owner=20walle?= =?UTF-8?q?t=20detection=20in=20siwe=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/curly-pumas-jam.md | 5 +++++ src/utils/useAuth.ts | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 .changeset/curly-pumas-jam.md diff --git a/.changeset/curly-pumas-jam.md b/.changeset/curly-pumas-jam.md new file mode 100644 index 000000000..29fef2b4e --- /dev/null +++ b/.changeset/curly-pumas-jam.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix owner wallet detection in siwe auth diff --git a/src/utils/useAuth.ts b/src/utils/useAuth.ts index ff5baaf9f..a2607bd7a 100644 --- a/src/utils/useAuth.ts +++ b/src/utils/useAuth.ts @@ -27,8 +27,11 @@ export default function useAuth(onDomainError: () => void, onSuccess?: (credenti const { mutate: signIn, ...mutation } = useMutation({ mutationFn: async ({ method, register }: { method: AuthMethod; register?: boolean }) => { queryClient.setQueryData(["method"], chain.id === base.id ? "siwe" : method); - if (method === "siwe" && getConnection(ownerConfig).isDisconnected) { - await connectOwner({ connector: await getOwnerConnector() }); + if (method === "siwe") { + const connection = getConnection(ownerConfig); + if (connection.isDisconnected || !connection.address) { + await connectOwner({ connector: await getOwnerConnector() }); + } } const credential = method === "siwe" || !register ? await getCredential() : await createCredential(); queryClient.setQueryData(["credential"], credential); From 126af9027bd4b3327b6c40f190e6d6c114fd040e Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Mon, 9 Mar 2026 17:45:46 -0300 Subject: [PATCH 64/84] =?UTF-8?q?=F0=9F=92=84=20app:=20fix=20repay=20amoun?= =?UTF-8?q?t=20selector=20font=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/jolly-teeth-flow.md | 5 +++++ src/components/pay-mode/RepayAmountSelector.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/jolly-teeth-flow.md diff --git a/.changeset/jolly-teeth-flow.md b/.changeset/jolly-teeth-flow.md new file mode 100644 index 000000000..75c157051 --- /dev/null +++ b/.changeset/jolly-teeth-flow.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 fix repay amount selector font style diff --git a/src/components/pay-mode/RepayAmountSelector.tsx b/src/components/pay-mode/RepayAmountSelector.tsx index 90fabd4e7..00ffb502d 100644 --- a/src/components/pay-mode/RepayAmountSelector.tsx +++ b/src/components/pay-mode/RepayAmountSelector.tsx @@ -146,7 +146,9 @@ export default function RepayAmountSelector({ onChangeText={handleAmountChange} onFocus={() => setFocused(true)} placeholder="0" - style={{ fontSize: 34, fontWeight: 400, letterSpacing: -0.2 }} + fontSize={34} + fontWeight="400" + letterSpacing={-0.2} textAlign="center" value={displayValue} /> From 75a9f49d1233b3d691a83dc45ddb542ee81f075d Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Wed, 11 Mar 2026 16:13:03 -0300 Subject: [PATCH 65/84] =?UTF-8?q?=F0=9F=92=84=20app:=20add=20bottom=20padd?= =?UTF-8?q?ing=20to=20swaps=20screen=20on=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/open-beds-stand.md | 5 +++++ src/components/swaps/Swaps.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/open-beds-stand.md diff --git a/.changeset/open-beds-stand.md b/.changeset/open-beds-stand.md new file mode 100644 index 000000000..7e135f695 --- /dev/null +++ b/.changeset/open-beds-stand.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +💄 add bottom padding to swaps screen on web diff --git a/src/components/swaps/Swaps.tsx b/src/components/swaps/Swaps.tsx index 15e053695..9c22d2a28 100644 --- a/src/components/swaps/Swaps.tsx +++ b/src/components/swaps/Swaps.tsx @@ -457,7 +457,7 @@ export default function Swaps() { - + {(caution || danger) && showWarning && ( From f525bc245c881d9d0f9124378347ef16653467a5 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Mon, 9 Mar 2026 12:54:18 -0300 Subject: [PATCH 66/84] =?UTF-8?q?=F0=9F=90=9B=20app:=20forward=20chain=20i?= =?UTF-8?q?d=20in=20account=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cool-icons-grow.md | 5 +++++ src/utils/accountClient.ts | 39 ++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 .changeset/cool-icons-grow.md diff --git a/.changeset/cool-icons-grow.md b/.changeset/cool-icons-grow.md new file mode 100644 index 000000000..7ad205cb4 --- /dev/null +++ b/.changeset/cool-icons-grow.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 forward chain id in account client diff --git a/src/utils/accountClient.ts b/src/utils/accountClient.ts index adda99fb8..2193f23ed 100644 --- a/src/utils/accountClient.ts +++ b/src/utils/accountClient.ts @@ -25,7 +25,14 @@ import { bufferToBase64URLString, type AuthenticatorAssertionResponseJSON, } from "@simplewebauthn/browser"; -import { getCallsStatus, getConnection, sendCalls, sendTransaction, signMessage } from "@wagmi/core/actions"; +import { + getCallsStatus, + getConnection, + sendCalls, + sendTransaction, + signMessage, + switchChain, +} from "@wagmi/core/actions"; import { bytesToBigInt, bytesToHex, @@ -33,6 +40,7 @@ import { concatHex, custom, encodeAbiParameters, + encodeFunctionData, encodePacked, ethAddress, hashMessage, @@ -154,7 +162,12 @@ export default async function createAccountClient({ credentialId, factory, x, y switch (method) { case "wallet_sendCalls": { if (!Array.isArray(params) || params.length !== 1) throw new Error("bad params"); - const { calls, from, id } = params[0] as { calls: readonly Call[]; from?: Address; id?: string }; + const { calls, chainId, from, id } = params[0] as { + calls: readonly Call[]; + chainId?: Hex; + from?: Address; + id?: string; + }; if (from && from !== accountAddress) throw new Error("bad account"); if (queryClient.getQueryData(["method"]) === "webauthn") { const { hash } = await client.sendUserOperation({ @@ -171,6 +184,7 @@ export default async function createAccountClient({ credentialId, factory, x, y try { return await sendCalls(ownerConfig, { id, + chainId: chainId ? hexToNumber(chainId) : chain.id, calls: [execute], capabilities: { paymasterService: { @@ -181,9 +195,23 @@ export default async function createAccountClient({ credentialId, factory, x, y }, }); } catch (error) { - reportError(error, { level: "warning" }); - const hash = await sendTransaction(ownerConfig, execute); - return { id: concat([hash, numberToHex(chain.id, { size: 32 }), TX_MAGIC_ID]) }; + reportError(error, { + level: "warning", + extra: error instanceof Error ? { cause: error.cause } : undefined, + }); + // TODO filter errors + const requestedChainId = chainId ? hexToNumber(chainId) : chain.id; + await switchChain(ownerConfig, { chainId: requestedChainId }); + try { + const hash = await sendTransaction(ownerConfig, { + to: accountAddress, + data: encodeFunctionData(execute), + chainId: requestedChainId, + }); + return { id: concat([hash, numberToHex(requestedChainId, { size: 32 }), TX_MAGIC_ID]) }; + } finally { + await switchChain(ownerConfig, { chainId: chain.id }).catch(reportError); + } } } case "wallet_getCallsStatus": { @@ -208,6 +236,7 @@ export default async function createAccountClient({ credentialId, factory, x, y try { const { to, data = "0x", value = 0n } = params[0] as TransactionRequest; const { id } = await sendCalls(ownerConfig, { + chainId: chain.id, calls: [ { to: accountAddress, From 7c8ec8c8d7fc36fdb493dc78b7ce46c5c208ab58 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Mon, 9 Mar 2026 13:53:32 -0300 Subject: [PATCH 67/84] =?UTF-8?q?=F0=9F=90=9B=20app:=20pass=20chain=20id?= =?UTF-8?q?=20to=20bridge=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/great-dryers-kick.md | 5 ++ src/components/add-funds/Bridge.tsx | 84 ++++++++++++++++------------- 2 files changed, 52 insertions(+), 37 deletions(-) create mode 100644 .changeset/great-dryers-kick.md diff --git a/.changeset/great-dryers-kick.md b/.changeset/great-dryers-kick.md new file mode 100644 index 000000000..3a09c3175 --- /dev/null +++ b/.changeset/great-dryers-kick.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 pass chain id to bridge calls diff --git a/src/components/add-funds/Bridge.tsx b/src/components/add-funds/Bridge.tsx index cf26b5052..00dc280a3 100644 --- a/src/components/add-funds/Bridge.tsx +++ b/src/components/add-funds/Bridge.tsx @@ -9,7 +9,7 @@ import { useToastController } from "@tamagui/toast"; import { ScrollView, Spinner, Square, XStack, YStack } from "tamagui"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { switchChain, waitForTransactionReceipt } from "@wagmi/core"; +import { switchChain, waitForCallsStatus, waitForTransactionReceipt } from "@wagmi/core"; import { encodeFunctionData, erc20Abi, @@ -116,7 +116,7 @@ export default function Bridge() { const previousSourceRef = useRef(undefined); - const effectiveSource = useMemo(() => { + const source = useMemo(() => { if (assetGroups.length === 0) return; const isValid = !!selectedSource && @@ -135,8 +135,8 @@ export default function Bridge() { if (group && asset) return { chain: group.chain.id, address: asset.token.address }; }, [assetGroups, selectedSource, bridge?.defaultChainId, bridge?.defaultTokenAddress]); - const selectedGroup = assetGroups.find((group) => group.chain.id === effectiveSource?.chain); - const selectedAsset = selectedGroup?.assets.find((asset) => asset.token.address === effectiveSource?.address); + const selectedGroup = assetGroups.find((group) => group.chain.id === source?.chain); + const selectedAsset = selectedGroup?.assets.find((asset) => asset.token.address === source?.address); const sourceToken = selectedAsset?.token; const sourceBalance = selectedAsset?.balance ?? 0n; @@ -144,8 +144,8 @@ export default function Bridge() { const sourceTokenSymbol = sourceToken?.symbol; const insufficientBalance = sourceAmount > sourceBalance; - const isSameChain = effectiveSource?.chain === chain.id; - const isNativeSource = effectiveSource?.address === zeroAddress; + const isSameChain = source?.chain === chain.id; + const isNativeSource = source?.address === zeroAddress; const destinationTokens = useMemo(() => bridge?.tokensByChain[chain.id] ?? [], [bridge?.tokensByChain]); const destinationBalances = useMemo(() => bridge?.balancesByChain[chain.id] ?? [], [bridge?.balancesByChain]); @@ -198,7 +198,7 @@ export default function Bridge() { const bridgeQuoteEnabled = !!senderAddress && !!account && - !!effectiveSource && + !!source && !!sourceToken && !!destinationToken && sourceAmount > 0n && @@ -215,7 +215,7 @@ export default function Bridge() { "quote", senderAddress, account, - effectiveSource, + source, sourceToken, destinationToken, sourceAmount, @@ -225,7 +225,7 @@ export default function Bridge() { if ( !senderAddress || !account || - !effectiveSource || + !source || !sourceToken || !destinationToken || sourceAmount === 0n || @@ -234,7 +234,7 @@ export default function Bridge() { throw new Error("invalid bridge parameters"); try { return await getRouteFrom({ - fromChainId: effectiveSource.chain, + fromChainId: source.chain, toChainId: chain.id, fromTokenAddress: sourceToken.address, toTokenAddress: destinationToken.address, @@ -269,8 +269,8 @@ export default function Bridge() { } = useSimulateContract({ config: senderConfig, account: senderAddress, - chainId: transferSimulationEnabled ? effectiveSource.chain : undefined, - address: transferSimulationEnabled ? getAddress(effectiveSource.address) : undefined, + chainId: transferSimulationEnabled ? source.chain : undefined, + address: transferSimulationEnabled ? getAddress(source.address) : undefined, abi: erc20Abi, functionName: "transfer", args: transferSimulationEnabled ? ([getAddress(account), sourceAmount] as const) : undefined, @@ -281,14 +281,14 @@ export default function Bridge() { if (transferSimulationError) reportError(transferSimulationError, { level: "warning" }); }, [transferSimulationError]); - const approvalTokenAddress = - effectiveSource?.address && isAddress(effectiveSource.address) ? effectiveSource.address : undefined; + const approvalTokenAddress = source?.address && isAddress(source.address) ? source.address : undefined; const approvalSpenderAddress = bridgeQuote?.estimate.approvalAddress; const approvalChainId = bridgeQuote?.chainId; const canReadAllowance = !!senderAddress && !!approvalTokenAddress && + approvalTokenAddress !== zeroAddress && !!approvalChainId && !!approvalSpenderAddress && approvalSpenderAddress !== zeroAddress && @@ -318,19 +318,15 @@ export default function Bridge() { setBridgePreview({ sourceToken, sourceAmount: BigInt(route.estimate.fromAmount) }); }, mutationFn: async (from) => { - if (!senderAddress || !effectiveSource || !account) throw new Error("missing bridge context"); + if (!senderAddress || !source || !account) throw new Error("missing bridge context"); if (isSameChain) throw new Error("invalid bridge context"); - - setBridgeStatus(t("Switching to {{chain}}...", { chain: selectedGroup?.chain.name ?? `Chain ${from.chainId}` })); - await switchChain(senderConfig, { chainId: from.chainId }); - const spender = from.estimate.approvalAddress; const requiresApproval = !!spender && spender !== zeroAddress && - effectiveSource.address !== zeroAddress && + source.address !== zeroAddress && isAddress(spender) && - isAddress(effectiveSource.address); + isAddress(source.address); let approval: Hex | undefined; let currentAllowance = allowanceData; @@ -357,24 +353,41 @@ export default function Bridge() { } } setBridgeStatus(t("Submitting bridge transaction...")); + let id: string | undefined; try { - await sendCallsTx({ + const result = await sendCallsTx({ + chainId: source.chain, calls: [ - ...(approval ? [{ to: getAddress(effectiveSource.address), data: approval }] : []), + ...(approval ? [{ to: getAddress(source.address), data: approval }] : []), { to: from.to, data: from.data, value: from.value }, ], }); - setBridgeStatus(t("Bridge transaction submitted")); + id = result.id; } catch (error) { - reportError(error); - if (approval) { - const hash = await sendTx({ to: getAddress(effectiveSource.address), data: approval }); - await waitForTransactionReceipt(senderConfig, { hash }); + if ( + error instanceof UserRejectedRequestError || + (error instanceof TransactionExecutionError && error.shortMessage === "User rejected the request.") + ) + throw error; + reportError(error, { level: "warning" }); + await switchChain(senderConfig, { chainId: source.chain }); + try { + if (approval) { + const hash = await sendTx({ chainId: source.chain, to: getAddress(source.address), data: approval }); + await waitForTransactionReceipt(senderConfig, { hash, chainId: source.chain }); + } + const hash = await sendTx({ chainId: source.chain, to: from.to, data: from.data, value: from.value }); + await waitForTransactionReceipt(senderConfig, { hash, chainId: source.chain }); + } finally { + await switchChain(senderConfig, { chainId: chain.id }).catch(reportError); } - const hash = await sendTx({ to: from.to, data: from.data, value: from.value }); - await waitForTransactionReceipt(senderConfig, { hash }); setBridgeStatus(t("Bridge transaction submitted")); + return; } + if (!id) throw new Error("missing sendCalls id"); + const { status } = await waitForCallsStatus(senderConfig, { id }); + if (status === "failure") throw new Error("failed to submit bridge transaction"); + setBridgeStatus(t("Bridge transaction submitted")); }, onSuccess: async () => { toast.show(t("Bridge transaction submitted"), { @@ -406,15 +419,13 @@ export default function Bridge() { setBridgePreview({ sourceToken, sourceAmount }); }, mutationFn: async () => { - if (!senderAddress || !effectiveSource || !account) throw new Error("missing transfer context"); + if (!senderAddress || !source || !account) throw new Error("missing transfer context"); if (!isSameChain) throw new Error("transfer mutation invoked for different chains"); - - await switchChain(senderConfig, { chainId: effectiveSource.chain }); setBridgeStatus(t("Submitting transfer transaction...")); const recipient = getAddress(account); let hash: Hex; if (isNativeSource) { - hash = await sendTx({ to: recipient, value: sourceAmount }); + hash = await sendTx({ chainId: source.chain, to: recipient, value: sourceAmount }); } else { if (!transferSimulation) throw new Error("missing transfer simulation"); hash = await transfer(transferSimulation.request); @@ -799,8 +810,7 @@ export default function Bridge() { {t("Source network")} - {selectedGroup?.chain.name ?? - (effectiveSource?.chain ? t("Chain {{id}}", { id: effectiveSource.chain }) : "—")} + {selectedGroup?.chain.name ?? (source?.chain ? t("Chain {{id}}", { id: source.chain }) : "—")} @@ -970,7 +980,7 @@ export default function Bridge() { setAssetSheetOpen(false); }} groups={assetGroups} - selected={effectiveSource} + selected={source} onSelect={(chainId, token) => { setSourceAmount(0n); setSelectedSource({ chain: chainId, address: token.address }); From 0f5dd1e482959cc614da25d11102ea261c890662 Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Tue, 10 Mar 2026 13:02:41 -0300 Subject: [PATCH 68/84] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=20app:=20migrate=20?= =?UTF-8?q?remaining=20flows=20to=20send=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/loose-papers-take.md | 5 + src/components/pay-mode/Pay.tsx | 133 ++++++++++++------- src/components/roll-debt/RollDebt.tsx | 76 ++++++----- src/components/send-funds/Amount.tsx | 182 +++++++++++++------------- src/components/swaps/Swaps.tsx | 79 ++++++++--- 5 files changed, 293 insertions(+), 182 deletions(-) create mode 100644 .changeset/loose-papers-take.md diff --git a/.changeset/loose-papers-take.md b/.changeset/loose-papers-take.md new file mode 100644 index 000000000..d54edf7de --- /dev/null +++ b/.changeset/loose-papers-take.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ migrate remaining flows to send calls diff --git a/src/components/pay-mode/Pay.tsx b/src/components/pay-mode/Pay.tsx index e7fd0ea46..6b0d664eb 100644 --- a/src/components/pay-mode/Pay.tsx +++ b/src/components/pay-mode/Pay.tsx @@ -11,8 +11,8 @@ import { ScrollView, Separator, XStack, YStack } from "tamagui"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { waitForCallsStatus } from "@wagmi/core/actions"; import { digits, nonEmpty, parse, pipe, safeParse, string, transform } from "valibot"; -import { ContractFunctionExecutionError, ContractFunctionRevertedError, erc20Abi } from "viem"; -import { useBytecode, useReadContract, useSendCalls, useSimulateContract, useWriteContract } from "wagmi"; +import { ContractFunctionExecutionError, ContractFunctionRevertedError, encodeFunctionData, erc20Abi } from "viem"; +import { useBytecode, useReadContract, useSendCalls, useSimulateContract } from "wagmi"; import accountInit from "@exactly/common/accountInit"; import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; @@ -28,6 +28,7 @@ import chain, { } from "@exactly/common/generated/chain"; import { auditorAbi, + exaPluginAbi, integrationPreviewerAbi, marketAbi, upgradeableModularAccountAbi, @@ -284,6 +285,7 @@ export default function Pay() { const { propose: { data: repayPropose }, executeProposal: { error: repayExecuteProposalError, isPending: isSimulatingRepay }, + proposalData: repayProposalData, } = useSimulateProposal({ account, amount: maxRepay, @@ -297,6 +299,7 @@ export default function Pay() { const { propose: { data: crossRepayPropose }, executeProposal: { error: crossRepayExecuteProposalError, isPending: isSimulatingCrossRepay }, + proposalData: crossRepayProposalData, } = useSimulateProposal({ account, amount: maxAmountIn, @@ -359,55 +362,86 @@ export default function Pay() { }); const { - mutate, + mutate: repay, isPending: isRepaying, isSuccess: isRepaySuccess, error: writeContractError, - } = useWriteContract({ - mutation: { - onSuccess: () => queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError), + } = useMutation({ + async mutationFn() { + if (!repayMarket) throw new Error("no repay market"); + setDisplayValues({ + amount: Number(withUSDC ? repayAssets : route?.fromAmount) / 10 ** repayMarket.decimals, + usdAmount: Number(previewValueUSD) / 1e18, + }); + const call = (() => { + switch (mode) { + case "repay": + if (!repayPropose || !selectedAsset.address) throw new Error("no repay simulation"); + return { + to: repayPropose.request.address, + data: encodeFunctionData({ + abi: exaPluginAbi, + functionName: "propose", + args: [selectedAsset.address, maxRepay ?? 0n, ProposalType.RepayAtMaturity, repayProposalData ?? "0x"], + }), + }; + + case "legacyRepay": { + if (!legacyRepaySimulation) throw new Error("no legacy repay simulation"); + const { address, abi, functionName, args } = legacyRepaySimulation.request; + return { to: address, data: encodeFunctionData({ abi, functionName, args }) }; + } + case "crossRepay": + if (!crossRepayPropose || !selectedAsset.address) throw new Error("no cross repay simulation"); + return { + to: crossRepayPropose.request.address, + data: encodeFunctionData({ + abi: exaPluginAbi, + functionName: "propose", + args: [ + selectedAsset.address, + maxAmountIn ?? 0n, + ProposalType.CrossRepayAtMaturity, + crossRepayProposalData ?? "0x", + ], + }), + }; + + case "legacyCrossRepay": { + if (!legacyCrossRepaySimulation) throw new Error("no legacy cross repay simulation"); + const { address, abi, functionName, args } = legacyCrossRepaySimulation.request; + return { to: address, data: encodeFunctionData({ abi, functionName, args }) }; + } + default: + throw new Error("unexpected mode"); + } + })(); + const { id } = await mutateSendCalls({ + calls: [call], + capabilities: { + paymasterService: { + url: `${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, + context: { policyId: alchemyGasPolicyId }, + }, + }, + }); + const { status } = await waitForCallsStatus(exa, { id }); + if (status === "failure") throw new Error("failed to repay"); + }, + onMutate() { + setEnableSimulations(false); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: assetQueryKey }).catch(reportError); + }, + onSettled() { + setEnableSimulations(true); + }, + onError(error) { + reportError(error); }, }); - const handlePayment = useCallback(() => { - if (!repayMarket) return; - setDisplayValues({ - amount: Number(withUSDC ? repayAssets : route?.fromAmount) / 10 ** repayMarket.decimals, - usdAmount: Number(previewValueUSD) / 1e18, - }); - switch (mode) { - case "repay": - if (!repayPropose) throw new Error("no repay simulation"); - mutate(repayPropose.request); - break; - case "legacyRepay": - if (!legacyRepaySimulation) throw new Error("no legacy repay simulation"); - mutate(legacyRepaySimulation.request); - break; - case "crossRepay": - if (!crossRepayPropose) throw new Error("no cross repay simulation"); - mutate(crossRepayPropose.request); - break; - case "legacyCrossRepay": - if (!legacyCrossRepaySimulation) throw new Error("no legacy cross repay simulation"); - mutate(legacyCrossRepaySimulation.request); - break; - } - setEnableSimulations(false); - }, [ - crossRepayPropose, - legacyCrossRepaySimulation, - legacyRepaySimulation, - mode, - previewValueUSD, - repayAssets, - repayMarket, - repayPropose, - route?.fromAmount, - withUSDC, - mutate, - ]); - const { mutateAsync: repayWithExternalAsset, isPending: isExternalRepaying, @@ -457,10 +491,15 @@ export default function Pay() { }, }, }); - setEnableSimulations(false); const { status } = await waitForCallsStatus(exa, { id }); if (status === "failure") throw new Error("failed to repay with external asset"); }, + onMutate() { + setEnableSimulations(false); + }, + onSettled() { + setEnableSimulations(true); + }, onError(error) { reportError(error); }, @@ -796,7 +835,7 @@ export default function Pay() { primary loading={loading && positionAssets > 0n} disabled={disabled} - onPress={selectedAsset.external ? () => repayWithExternalAsset() : handlePayment} + onPress={selectedAsset.external ? () => repayWithExternalAsset() : () => repay()} > {handleButtonText()} diff --git a/src/components/roll-debt/RollDebt.tsx b/src/components/roll-debt/RollDebt.tsx index d8cb7290a..fe8b38faa 100644 --- a/src/components/roll-debt/RollDebt.tsx +++ b/src/components/roll-debt/RollDebt.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React from "react"; import { useTranslation } from "react-i18next"; import { Pressable } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -9,11 +9,15 @@ import { ArrowLeft, ArrowRight } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, Separator, Spinner, XStack, YStack } from "tamagui"; +import { useMutation } from "@tanstack/react-query"; +import { waitForCallsStatus } from "@wagmi/core/actions"; import { nonEmpty, pipe, safeParse, string } from "valibot"; -import { ContractFunctionExecutionError, encodeAbiParameters } from "viem"; -import { useBytecode, useWriteContract } from "wagmi"; +import { ContractFunctionExecutionError, encodeAbiParameters, encodeFunctionData } from "viem"; +import { useBytecode, useSendCalls } from "wagmi"; -import { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; +import alchemyGasPolicyId from "@exactly/common/alchemyGasPolicyId"; +import chain, { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerPreviewBorrowAtMaturity, @@ -28,6 +32,7 @@ import View from "../../components/shared/View"; import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; import useAsset from "../../utils/useAsset"; +import exa from "../../utils/wagmi/exa"; import Button from "../shared/Button"; import Skeleton from "../shared/Skeleton"; @@ -269,38 +274,47 @@ function RolloverButton({ query: { enabled: !!address && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); + const { mutateAsync: mutateSendCalls } = useSendCalls(); const { - mutate, + mutate: proposeRollDebt, isPending: isProposeRollDebtPending, error: proposeRollDebtError, - } = useWriteContract({ - mutation: { - onSuccess: () => { - toast.show(t("Processing rollover"), { - native: true, - duration: 1000, - burntOptions: { haptic: "success", preset: "done" }, - }); - if (address && bytecode) refetchPendingProposals().catch(reportError); - router.dismissTo("/activity"); - }, - onError: (error) => { - toast.show(t("Rollover failed"), { - native: true, - duration: 1000, - burntOptions: { haptic: "error", preset: "error" }, - }); - reportError(error); - }, + } = useMutation({ + async mutationFn() { + if (!address) throw new Error("no address"); + if (!proposeSimulation) throw new Error("no propose roll debt simulation"); + const { address: to, abi, functionName, args } = proposeSimulation.request; + const { id } = await mutateSendCalls({ + calls: [{ to, data: encodeFunctionData({ abi, functionName, args }) }], + capabilities: { + paymasterService: { + url: `${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, + context: { policyId: alchemyGasPolicyId }, + }, + }, + }); + const { status } = await waitForCallsStatus(exa, { id }); + if (status === "failure") throw new Error("failed to propose rollover"); + }, + onSuccess() { + toast.show(t("Processing rollover"), { + native: true, + duration: 1000, + burntOptions: { haptic: "success", preset: "done" }, + }); + if (address && bytecode) refetchPendingProposals().catch(reportError); + router.dismissTo("/activity"); + }, + onError(error) { + toast.show(t("Rollover failed"), { + native: true, + duration: 1000, + burntOptions: { haptic: "error", preset: "error" }, + }); + reportError(error); }, }); - const proposeRollDebt = useCallback(() => { - if (!address) throw new Error("no address"); - if (!proposeSimulation) throw new Error("no propose roll debt simulation"); - mutate(proposeSimulation.request); - }, [address, proposeSimulation, mutate]); - const hasProposed = pendingProposals?.some( ({ proposal }) => proposal.market === marketUSDCAddress && @@ -319,7 +333,7 @@ function RolloverButton({ !!isError || isProposeRollDebtPending || isPendingProposalsPending || !proposeSimulation || hasProposed; return ( + )} + { + queryClient.invalidateQueries({ queryKey: ["swap"] }).catch(reportError); + onClose(); }} - stickyHeaderHiddenOnScroll > - - - - - - - - - - - - - }} - /> - - - {`$${fromUsdAmount.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} - - - - - {Number(formatUnits(fromAmount, fromToken.decimals)).toFixed(8)} - - - - - {`$${toUsdAmount.toLocaleString(language, { style: "decimal", minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} - - - - - {Number(formatUnits(toAmount, toToken.decimals)).toFixed(8)} - - - - - - - - - - { - queryClient.invalidateQueries({ queryKey: ["swap"] }).catch(reportError); - router.dismissTo("/activity"); - }} - > - - {t("Close")} - - - - - - + + {t("Close")} + + + ); diff --git a/src/components/swaps/Swaps.tsx b/src/components/swaps/Swaps.tsx index df5b60d2d..267a61414 100644 --- a/src/components/swaps/Swaps.tsx +++ b/src/components/swaps/Swaps.tsx @@ -634,6 +634,7 @@ export default function Swaps() { return ( { onClose(); }} From f4c97f74f054dea4febf7ad5228f8eb5f3824c5b Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Wed, 11 Mar 2026 17:48:10 -0300 Subject: [PATCH 70/84] =?UTF-8?q?=F0=9F=90=9B=20app:=20fix=20swaps=20query?= =?UTF-8?q?=20invalidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cyan-flies-camp.md | 5 +++++ src/components/swaps/Swaps.tsx | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/cyan-flies-camp.md diff --git a/.changeset/cyan-flies-camp.md b/.changeset/cyan-flies-camp.md new file mode 100644 index 000000000..4f21c256a --- /dev/null +++ b/.changeset/cyan-flies-camp.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +🐛 fix swaps query invalidation diff --git a/src/components/swaps/Swaps.tsx b/src/components/swaps/Swaps.tsx index 267a61414..94b728ee4 100644 --- a/src/components/swaps/Swaps.tsx +++ b/src/components/swaps/Swaps.tsx @@ -387,6 +387,7 @@ export default function Swaps() { }); const { status } = await waitForCallsStatus(exaConfig, { id }); if (status === "failure") throw new Error("failed to swap"); + await queryClient.invalidateQueries({ queryKey: ["lifi", "tokenBalances"] }); }, onMutate() { updateSwap((old) => ({ ...old, enableSimulations: false })); From a17f10b8defe4f4195c06dfa5516590a8c15d15b Mon Sep 17 00:00:00 2001 From: guillermo dieguez Date: Wed, 11 Mar 2026 17:41:15 -0300 Subject: [PATCH 71/84] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20app:=20pass=20explic?= =?UTF-8?q?it=20chain=20id=20to=20read=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/wide-cats-hug.md | 5 ++++ src/components/card/Card.tsx | 4 +++- src/components/card/exa-card/CardContents.tsx | 3 ++- src/components/defi/DeFi.tsx | 4 +++- .../getting-started/GettingStarted.tsx | 4 +++- src/components/home/AssetList.tsx | 4 +++- src/components/home/CardLimits.tsx | 3 ++- src/components/home/Home.tsx | 6 ++++- src/components/home/HomeActions.tsx | 6 +++-- src/components/home/Portfolio.tsx | 3 ++- .../home/card-upgrade/UpgradeAccount.tsx | 3 ++- src/components/loans/Amount.tsx | 5 ++-- src/components/loans/Asset.tsx | 3 ++- src/components/loans/CreditLine.tsx | 5 ++-- src/components/loans/LoanSummary.tsx | 9 ++++++-- src/components/loans/Loans.tsx | 3 ++- src/components/loans/Review.tsx | 4 +++- src/components/pay-mode/OverduePayments.tsx | 6 +++-- src/components/pay-mode/Pay.tsx | 8 ++++++- src/components/pay-mode/PayMode.tsx | 3 ++- src/components/pay-mode/PaySelector.tsx | 4 +++- src/components/pay-mode/UpcomingPayments.tsx | 6 +++-- src/components/roll-debt/RollDebt.tsx | 3 ++- src/components/send-funds/Amount.tsx | 7 +++++- src/components/shared/InstallmentSelector.tsx | 3 ++- src/components/shared/PluginUpgrade.tsx | 6 +++-- src/components/swaps/Swaps.tsx | 2 ++ src/utils/useAsset.ts | 3 ++- src/utils/usePendingOperations.ts | 5 ++-- src/utils/usePortfolio.ts | 4 +++- src/utils/useSimulateProposal.ts | 23 +++++++++++++++---- 31 files changed, 117 insertions(+), 40 deletions(-) create mode 100644 .changeset/wide-cats-hug.md diff --git a/.changeset/wide-cats-hug.md b/.changeset/wide-cats-hug.md new file mode 100644 index 000000000..9c5b922d7 --- /dev/null +++ b/.changeset/wide-cats-hug.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ pass explicit chain id to read hooks diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx index c7cbb7b8a..95184b3fe 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -11,7 +11,7 @@ import { ScrollView, Separator, Spinner, Square, Switch, XStack, YStack } from " import { useMutation, useQuery } from "@tanstack/react-query"; import accountInit from "@exactly/common/accountInit"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly, useReadUpgradeableModularAccountGetInstalledPlugins, @@ -99,6 +99,7 @@ export default function Card() { const { refetch: refetchInstalledPlugins, isFetching: isFetchingPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!address && !!credential }, @@ -110,6 +111,7 @@ export default function Card() { isFetching: isFetchingMarkets, } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/card/exa-card/CardContents.tsx b/src/components/card/exa-card/CardContents.tsx index 8281eb36f..ab136c8cb 100644 --- a/src/components/card/exa-card/CardContents.tsx +++ b/src/components/card/exa-card/CardContents.tsx @@ -6,7 +6,7 @@ import { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { Loader, LockKeyhole, Snowflake } from "@tamagui/lucide-icons"; import { AnimatePresence, XStack, YStack } from "tamagui"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { PLATINUM_PRODUCT_ID } from "@exactly/common/panda"; import { borrowLimit, withdrawLimit } from "@exactly/lib"; @@ -34,6 +34,7 @@ export default function CardContents({ const { address } = useAccount(); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/defi/DeFi.tsx b/src/components/defi/DeFi.tsx index c8840eead..aba21cfbd 100644 --- a/src/components/defi/DeFi.tsx +++ b/src/components/defi/DeFi.tsx @@ -10,6 +10,8 @@ import { ScrollView, useTheme, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; +import chain from "@exactly/common/generated/chain"; + import AboutDefiSheet from "./AboutDefiSheet"; import ConnectionSheet from "./ConnectionSheet"; import DisconnectSheet from "./DisconnectSheet"; @@ -32,7 +34,7 @@ export default function DeFi() { const { data: fundingConnected } = useQuery({ queryKey: ["defi", "usdc-funding-connected"] }); const { data: lifiConnected } = useQuery({ queryKey: ["defi", "lifi-connected"] }); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const [aboutDefiSheetOpen, setAboutDefiSheetOpen] = useState(false); const [fundingSheetOpen, setFundingSheetOpen] = useState(false); const [lifiSheetOpen, setLifiSheetOpen] = useState(false); diff --git a/src/components/getting-started/GettingStarted.tsx b/src/components/getting-started/GettingStarted.tsx index f6e2be629..4e71350b4 100644 --- a/src/components/getting-started/GettingStarted.tsx +++ b/src/components/getting-started/GettingStarted.tsx @@ -10,6 +10,8 @@ import { ScrollView, XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; +import chain from "@exactly/common/generated/chain"; + import Step from "./Step"; import { presentArticle } from "../../utils/intercom"; import reportError from "../../utils/reportError"; @@ -25,7 +27,7 @@ import type { KYCStatus } from "../../utils/server"; function useOnboardingState() { const { address: account } = useAccount(); - const { data: bytecode } = useBytecode({ address: account, query: { enabled: !!account } }); + const { data: bytecode } = useBytecode({ address: account, chainId: chain.id, query: { enabled: !!account } }); const { data: kycStatus } = useQuery({ queryKey: ["kyc", "status"] }); const isDeployed = !!bytecode; const hasKYC = Boolean( diff --git a/src/components/home/AssetList.tsx b/src/components/home/AssetList.tsx index fe8c7691e..f940c8735 100644 --- a/src/components/home/AssetList.tsx +++ b/src/components/home/AssetList.tsx @@ -6,7 +6,7 @@ import { XStack, YStack } from "tamagui"; import { parseUnits } from "viem"; -import { previewerAddress, ratePreviewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress, ratePreviewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly, useReadRatePreviewerSnapshot } from "@exactly/common/generated/hooks"; import { floatingDepositRates } from "@exactly/lib"; @@ -110,12 +110,14 @@ export default function AssetList() { const { address } = useAccount(); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); const { externalAssets } = usePortfolio(); const { data: snapshots, dataUpdatedAt } = useReadRatePreviewerSnapshot({ address: ratePreviewerAddress, + chainId: chain.id, }); const rates = snapshots ? floatingDepositRates(snapshots, Math.floor(dataUpdatedAt / 1000)) : []; diff --git a/src/components/home/CardLimits.tsx b/src/components/home/CardLimits.tsx index 6c78b5a75..1e5d681ff 100644 --- a/src/components/home/CardLimits.tsx +++ b/src/components/home/CardLimits.tsx @@ -8,7 +8,7 @@ import { XStack, YStack } from "tamagui"; import { useQuery } from "@tanstack/react-query"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { borrowLimit, WAD, withdrawLimit } from "@exactly/lib"; @@ -28,6 +28,7 @@ export default function CardLimits({ onPress }: { onPress: () => void }) { const { data: card } = useQuery({ queryKey: ["card", "details"] }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 095d15053..c5de12e9a 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -11,7 +11,7 @@ import { useQuery } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; import accountInit from "@exactly/common/accountInit"; -import { exaPluginAddress, exaPreviewerAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { exaPluginAddress, exaPreviewerAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerExactly, @@ -64,10 +64,12 @@ export default function Home() { const { data: credential } = useQuery({ queryKey: ["credential"] }); const { data: bytecode, refetch: refetchBytecode } = useBytecode({ address: account, + chainId: chain.id, query: { enabled: !!account }, }); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: account, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!account && !!credential }, @@ -88,6 +90,7 @@ export default function Home() { }); const { refetch: refetchPendingProposals } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); @@ -98,6 +101,7 @@ export default function Home() { isFetching: isFetchingPreviewer, } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/components/home/HomeActions.tsx b/src/components/home/HomeActions.tsx index e8ceff87b..eec98a5eb 100644 --- a/src/components/home/HomeActions.tsx +++ b/src/components/home/HomeActions.tsx @@ -10,7 +10,7 @@ import { useQuery } from "@tanstack/react-query"; import { useBytecode, useReadContract } from "wagmi"; import accountInit from "@exactly/common/accountInit"; -import { exaPluginAddress } from "@exactly/common/generated/chain"; +import chain, { exaPluginAddress } from "@exactly/common/generated/chain"; import { upgradeableModularAccountAbi, useReadUpgradeableModularAccountGetInstalledPlugins, @@ -26,7 +26,7 @@ export default function HomeActions() { const router = useRouter(); const { address: account } = useAccount(); const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address: account, query: { enabled: !!account } }); + const { data: bytecode } = useBytecode({ address: account, chainId: chain.id, query: { enabled: !!account } }); const { t } = useTranslation(); const actions = useMemo( () => [ @@ -38,12 +38,14 @@ export default function HomeActions() { const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: account, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!account && !!credential }, }); const isLatestPlugin = installedPlugins?.[0] === exaPluginAddress; const { refetch: fetchProposals, isPending } = useReadContract({ + chainId: chain.id, functionName: "proposals", abi: [ ...upgradeableModularAccountAbi, diff --git a/src/components/home/Portfolio.tsx b/src/components/home/Portfolio.tsx index ed85231ec..eb0908a9e 100644 --- a/src/components/home/Portfolio.tsx +++ b/src/components/home/Portfolio.tsx @@ -7,7 +7,7 @@ import { useRouter } from "expo-router"; import { ArrowLeft, CircleHelp } from "@tamagui/lucide-icons"; import { ScrollView, XStack } from "tamagui"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import AssetList from "./AssetList"; @@ -33,6 +33,7 @@ export default function Portfolio() { const { refetch: refetchMarkets, isFetching: isFetchingMarkets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/home/card-upgrade/UpgradeAccount.tsx b/src/components/home/card-upgrade/UpgradeAccount.tsx index cb468a2a9..afec8ebc2 100644 --- a/src/components/home/card-upgrade/UpgradeAccount.tsx +++ b/src/components/home/card-upgrade/UpgradeAccount.tsx @@ -40,11 +40,12 @@ export default function UpgradeAccount() { const { data: installedPlugins, refetch: refetchInstalledPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { refetchOnMount: true, enabled: !!address && !!credential }, }); - const { data: pluginManifest } = useReadExaPluginPluginManifest({ address: exaPluginAddress }); + const { data: pluginManifest } = useReadExaPluginPluginManifest({ address: exaPluginAddress, chainId: chain.id }); const isLatestPlugin = installedPlugins?.[0] === exaPluginAddress; const toast = useToastController(); diff --git a/src/components/loans/Amount.tsx b/src/components/loans/Amount.tsx index c2e725eea..df650882b 100644 --- a/src/components/loans/Amount.tsx +++ b/src/components/loans/Amount.tsx @@ -11,7 +11,7 @@ import { useQuery } from "@tanstack/react-query"; import { formatUnits } from "viem"; import { useBytecode } from "wagmi"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import AmountSelector from "./AmountSelector"; @@ -34,9 +34,10 @@ export default function Amount() { t, i18n: { language }, } = useTranslation(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!bytecode && !!address }, }); diff --git a/src/components/loans/Asset.tsx b/src/components/loans/Asset.tsx index b787f711d..b157a3531 100644 --- a/src/components/loans/Asset.tsx +++ b/src/components/loans/Asset.tsx @@ -7,7 +7,7 @@ import { useRouter } from "expo-router"; import { ArrowLeft, ArrowRight, Check, CircleHelp } from "@tamagui/lucide-icons"; import { ScrollView, XStack, YStack } from "tamagui"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { presentArticle } from "../../utils/intercom"; @@ -27,6 +27,7 @@ export default function Asset() { const [selectedMarket, setSelectedMarket] = useState(); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); diff --git a/src/components/loans/CreditLine.tsx b/src/components/loans/CreditLine.tsx index 0b25ea605..a8bcf47e4 100644 --- a/src/components/loans/CreditLine.tsx +++ b/src/components/loans/CreditLine.tsx @@ -9,7 +9,7 @@ import { Separator, XStack, YStack } from "tamagui"; import { formatUnits } from "viem"; import { useBytecode } from "wagmi"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { borrowLimit } from "@exactly/lib"; @@ -27,9 +27,10 @@ export default function CreditLine() { t, i18n: { language }, } = useTranslation(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!bytecode && !!address }, }); diff --git a/src/components/loans/LoanSummary.tsx b/src/components/loans/LoanSummary.tsx index bfcc474b1..5e223e88e 100644 --- a/src/components/loans/LoanSummary.tsx +++ b/src/components/loans/LoanSummary.tsx @@ -5,7 +5,7 @@ import { XStack, YStack } from "tamagui"; import { useBytecode } from "wagmi"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerPreviewBorrowAtMaturity } from "@exactly/common/generated/hooks"; import { MATURITY_INTERVAL, WAD } from "@exactly/lib"; @@ -24,7 +24,11 @@ export default function LoanSummary({ loan }: { loan: Loan }) { i18n: { language }, } = useTranslation(); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address: previewerAddress, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ + address: previewerAddress, + chainId: chain.id, + query: { enabled: !!address }, + }); const { market, isFetching: isMarketFetching } = useAsset(loan.market); const symbol = market?.symbol.slice(3) === "WETH" ? "ETH" : market?.symbol.slice(3); const isBorrow = loan.installments === 1; @@ -38,6 +42,7 @@ export default function LoanSummary({ loan }: { loan: Loan }) { }); const { data: borrow, isLoading: isBorrowPending } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: loan.market && loan.amount ? [loan.market, loan.maturity ?? BigInt(defaultMaturity), loan.amount] : undefined, query: { enabled: isBorrow && !!loan.amount && !!loan.market && !!address && !!bytecode, diff --git a/src/components/loans/Loans.tsx b/src/components/loans/Loans.tsx index 469cd1a38..e5ac228a4 100644 --- a/src/components/loans/Loans.tsx +++ b/src/components/loans/Loans.tsx @@ -7,7 +7,7 @@ import { useRouter } from "expo-router"; import { ArrowLeft, CircleHelp } from "@tamagui/lucide-icons"; import { ScrollView, XStack, YStack } from "tamagui"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import CreditLine from "./CreditLine"; @@ -27,6 +27,7 @@ export default function Loans() { const router = useRouter(); const { refetch, isPending } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/components/loans/Review.tsx b/src/components/loans/Review.tsx index 8f49a47ff..09b6f5d56 100644 --- a/src/components/loans/Review.tsx +++ b/src/components/loans/Review.tsx @@ -72,10 +72,11 @@ export default function Review() { const singleInstallment = count === 1; const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: borrow, isPending: isBorrowPending } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: [marketUSDCAddress, maturity ?? 0n, amount ?? 0n], query: { enabled: !!address && !!bytecode && !!maturity && !!amount && singleInstallment }, }); @@ -187,6 +188,7 @@ export default function Review() { const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!address && !!credential }, diff --git a/src/components/pay-mode/OverduePayments.tsx b/src/components/pay-mode/OverduePayments.tsx index f082ef42a..64ec4fe65 100644 --- a/src/components/pay-mode/OverduePayments.tsx +++ b/src/components/pay-mode/OverduePayments.tsx @@ -7,7 +7,7 @@ import { XStack, YStack } from "tamagui"; import { isBefore } from "date-fns"; import { useBytecode } from "wagmi"; -import { exaPreviewerAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { exaPreviewerAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import ProposalType, { decodeCrossRepayAtMaturity, @@ -27,14 +27,16 @@ export default function OverduePayments({ onSelect }: { onSelect: (maturity: big i18n: { language }, } = useTranslation(); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: pendingProposals } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address && !!bytecode, refetchInterval: 30_000 }, }); diff --git a/src/components/pay-mode/Pay.tsx b/src/components/pay-mode/Pay.tsx index 6b0d664eb..fb7983f10 100644 --- a/src/components/pay-mode/Pay.tsx +++ b/src/components/pay-mode/Pay.tsx @@ -101,9 +101,10 @@ export default function Pay() { }); const { mutateAsync: mutateSendCalls } = useSendCalls(); const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address: account, query: { enabled: !!account } }); + const { data: bytecode } = useBytecode({ address: account, chainId: chain.id, query: { enabled: !!account } }); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address: account, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!account && !!credential }, @@ -133,6 +134,7 @@ export default function Pay() { const { data: fixedRepaySnapshot } = useReadContract({ address: integrationPreviewerAddress, + chainId: chain.id, abi: integrationPreviewerAbi, functionName: "fixedRepaySnapshot", args: account ? [account, marketUSDCAddress, maturity ?? 0n] : undefined, @@ -141,6 +143,7 @@ export default function Pay() { const { data: proposalDelay, isLoading: isProposalDelayLoading } = useReadProposalManagerDelay({ address: proposalManagerAddress, + chainId: chain.id, }); const simulationTimestamp = proposalDelay === undefined ? undefined : Math.floor(Date.now() / 1000) + Number(proposalDelay); @@ -164,6 +167,7 @@ export default function Pay() { const { data: balancerUSDCBalance } = useReadContract({ address: usdcAddress, + chainId: chain.id, abi: erc20Abi, functionName: "balanceOf", args: balancerVaultAddress ? [balancerVaultAddress] : undefined, @@ -318,6 +322,7 @@ export default function Pay() { isPending: isSimulatingLegacyRepay, } = useSimulateContract({ address: account, + chainId: chain.id, functionName: "repay", args: [maturity ?? 0n], abi: [ @@ -341,6 +346,7 @@ export default function Pay() { isPending: isSimulatingLegacyCrossRepay, } = useSimulateContract({ address: account, + chainId: chain.id, functionName: "crossRepay", args: selectedAsset.address && maturity ? [maturity, selectedAsset.address] : undefined, abi: [ diff --git a/src/components/pay-mode/PayMode.tsx b/src/components/pay-mode/PayMode.tsx index 0bb9093b9..81a4183b0 100644 --- a/src/components/pay-mode/PayMode.tsx +++ b/src/components/pay-mode/PayMode.tsx @@ -6,7 +6,7 @@ import { useRouter } from "expo-router"; import { ScrollView, XStack } from "tamagui"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import OverduePayments from "./OverduePayments"; @@ -28,6 +28,7 @@ export default function PayMode() { const router = useRouter(); const { refetch, isPending } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/components/pay-mode/PaySelector.tsx b/src/components/pay-mode/PaySelector.tsx index 1c974f61f..4d4d358b5 100644 --- a/src/components/pay-mode/PaySelector.tsx +++ b/src/components/pay-mode/PaySelector.tsx @@ -9,7 +9,7 @@ import { XStack, YStack } from "tamagui"; import { useMutation, useQuery } from "@tanstack/react-query"; import { formatUnits, parseUnits } from "viem"; -import { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly, useReadPreviewerPreviewBorrowAtMaturity } from "@exactly/common/generated/hooks"; import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS"; import { borrowLimit, WAD, withdrawLimit } from "@exactly/lib"; @@ -41,6 +41,7 @@ export default function PaySelector() { const { address } = useAccount(); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address }, }); @@ -279,6 +280,7 @@ function InstallmentButton({ }); const { data: borrowPreview, isLoading: isBorrowPreviewLoading } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: market && account && firstMaturity && calculationAssets ? [market.market, BigInt(firstMaturity), calculationAssets] diff --git a/src/components/pay-mode/UpcomingPayments.tsx b/src/components/pay-mode/UpcomingPayments.tsx index a3824f8db..439149f02 100644 --- a/src/components/pay-mode/UpcomingPayments.tsx +++ b/src/components/pay-mode/UpcomingPayments.tsx @@ -7,7 +7,7 @@ import { XStack, YStack } from "tamagui"; import { isBefore } from "date-fns"; import { useBytecode } from "wagmi"; -import { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; +import chain, { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import ProposalType, { decodeCrossRepayAtMaturity, @@ -27,14 +27,16 @@ export default function UpcomingPayments({ onSelect }: { onSelect: (maturity: bi i18n: { language }, } = useTranslation(); const { address } = useAccount(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: pendingProposals } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: address ? [address] : undefined, query: { enabled: !!address && !!bytecode, refetchInterval: 30_000 }, }); diff --git a/src/components/roll-debt/RollDebt.tsx b/src/components/roll-debt/RollDebt.tsx index fe8b38faa..4b556ef40 100644 --- a/src/components/roll-debt/RollDebt.tsx +++ b/src/components/roll-debt/RollDebt.tsx @@ -56,10 +56,11 @@ export default function Pay() { const borrow = exaUSDC?.fixedBorrowPositions.find((b) => b.maturity === BigInt(success ? repayMaturity : 0)); const rolloverMaturityBorrow = exaUSDC?.fixedBorrowPositions.find((b) => b.maturity === BigInt(borrowMaturity)); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: borrowPreview } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: [marketUSDCAddress, BigInt(borrowMaturity), borrow?.previewValue ?? 0n], query: { enabled: !!bytecode && !!exaUSDC && !!borrow && !!address && !!borrowMaturity }, }); diff --git a/src/components/send-funds/Amount.tsx b/src/components/send-funds/Amount.tsx index 9e895e599..faa87fab5 100644 --- a/src/components/send-funds/Amount.tsx +++ b/src/components/send-funds/Amount.tsx @@ -74,9 +74,10 @@ export default function Amount() { const formAmount = useStore(form.store, (state) => state.values.amount); const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: installedPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { enabled: !!address && !!credential }, @@ -87,6 +88,7 @@ export default function Amount() { !!market && !!address && !!bytecode && formAmount > 0n && !!receiver && receiver !== zeroAddress; const { data: proposeSimulation } = useSimulateContract({ address, + chainId: chain.id, functionName: "propose", abi: exaPluginAbi, args: [ @@ -99,6 +101,7 @@ export default function Amount() { }); const { data: legacyProposeSimulation } = useSimulateContract({ address, + chainId: chain.id, functionName: "propose", abi: [ ...upgradeableModularAccountAbi, @@ -127,6 +130,7 @@ export default function Amount() { const { data: erc20TransferSimulation } = useSimulateContract({ address: externalAddress, + chainId: chain.id, abi: erc20Abi, functionName: "transfer", args: receiver ? [receiver, formAmount] : undefined, @@ -143,6 +147,7 @@ export default function Amount() { }); const { data: nativeTransferEstimate } = useEstimateGas({ + chainId: chain.id, to: receiver, value: formAmount, query: { diff --git a/src/components/shared/InstallmentSelector.tsx b/src/components/shared/InstallmentSelector.tsx index c2edd57b1..4af51ba09 100644 --- a/src/components/shared/InstallmentSelector.tsx +++ b/src/components/shared/InstallmentSelector.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Check } from "@tamagui/lucide-icons"; import { XStack, YStack } from "tamagui"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerPreviewBorrowAtMaturity } from "@exactly/common/generated/hooks"; import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS"; @@ -76,6 +76,7 @@ function Installment({ const { data: borrow, isLoading: isBorrowPending } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, + chainId: chain.id, args: market ? [market.market, BigInt(firstMaturity), totalAmount] : undefined, query: { enabled: isBorrow && totalAmount > 0n && !!market && !!account && !!firstMaturity }, }); diff --git a/src/components/shared/PluginUpgrade.tsx b/src/components/shared/PluginUpgrade.tsx index e77902968..9097c2cf7 100644 --- a/src/components/shared/PluginUpgrade.tsx +++ b/src/components/shared/PluginUpgrade.tsx @@ -30,18 +30,20 @@ export default function PluginUpgrade() { const { mutateAsync: mutateSendCalls } = useSendCalls(); const { address } = useAccount(); const { data: credential } = useQuery({ queryKey: ["credential"] }); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); + const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); const { data: installedPlugins, refetch: refetchInstalledPlugins } = useReadUpgradeableModularAccountGetInstalledPlugins({ address, + chainId: chain.id, factory: credential?.factory, factoryData: credential && accountInit(credential), query: { refetchOnMount: true, enabled: !!address && !!credential }, }); - const { data: pluginManifest } = useReadExaPluginPluginManifest({ address: exaPluginAddress }); + const { data: pluginManifest } = useReadExaPluginPluginManifest({ address: exaPluginAddress, chainId: chain.id }); const isLatestPlugin = installedPlugins?.[0] === exaPluginAddress; const { data: uninstallPluginSimulation } = useSimulateUpgradeableModularAccountUninstallPlugin({ address, + chainId: chain.id, args: installedPlugins?.[0] ? [installedPlugins[0], "0x", "0x"] : undefined, query: { enabled: !!address && !!installedPlugins?.[0] && !!bytecode && !isLatestPlugin }, }); diff --git a/src/components/swaps/Swaps.tsx b/src/components/swaps/Swaps.tsx index 94b728ee4..cbe93af06 100644 --- a/src/components/swaps/Swaps.tsx +++ b/src/components/swaps/Swaps.tsx @@ -84,6 +84,7 @@ export default function Swaps() { const [activeInput, setActiveInput] = useState<"from" | "to">("from"); const { data: markets } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); @@ -289,6 +290,7 @@ export default function Swaps() { isPending: isSimulatingExternalSwap, } = useSimulateContract({ address: account, + chainId: chain.id, functionName: "swap", args: [ parse(Address, fromToken?.token.address ?? zeroAddress), diff --git a/src/utils/useAsset.ts b/src/utils/useAsset.ts index 8691c8d5f..1ea5190c3 100644 --- a/src/utils/useAsset.ts +++ b/src/utils/useAsset.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { previewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly } from "@exactly/common/generated/hooks"; import { borrowLimit, withdrawLimit } from "@exactly/lib"; @@ -19,6 +19,7 @@ export default function useAsset(address?: Address) { isFetching: isMarketsFetching, } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/utils/usePendingOperations.ts b/src/utils/usePendingOperations.ts index b76fe2f6e..aea569c63 100644 --- a/src/utils/usePendingOperations.ts +++ b/src/utils/usePendingOperations.ts @@ -3,7 +3,7 @@ import { useMemo } from "react"; import { useMutationState } from "@tanstack/react-query"; import { useBytecode } from "wagmi"; -import { exaPreviewerAddress } from "@exactly/common/generated/chain"; +import chain, { exaPreviewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals } from "@exactly/common/generated/hooks"; import useAccount from "./useAccount"; @@ -14,10 +14,11 @@ import type { MutationState } from "@tanstack/react-query"; export default function usePendingOperations() { const { address: exaAccount } = useAccount({ config: exa }); - const { data: bytecode } = useBytecode({ address: exaAccount, query: { enabled: !!exaAccount } }); + const { data: bytecode } = useBytecode({ address: exaAccount, chainId: chain.id, query: { enabled: !!exaAccount } }); const proposals = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, + chainId: chain.id, args: exaAccount ? [exaAccount] : undefined, query: { enabled: !!exaAccount && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, }); diff --git a/src/utils/usePortfolio.ts b/src/utils/usePortfolio.ts index 445a47c3e..558777dec 100644 --- a/src/utils/usePortfolio.ts +++ b/src/utils/usePortfolio.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { previewerAddress, ratePreviewerAddress } from "@exactly/common/generated/chain"; +import chain, { previewerAddress, ratePreviewerAddress } from "@exactly/common/generated/chain"; import { useReadPreviewerExactly, useReadRatePreviewerSnapshot } from "@exactly/common/generated/hooks"; import { floatingDepositRates, withdrawLimit } from "@exactly/lib"; @@ -43,9 +43,11 @@ export default function usePortfolio(account_?: Hex, options?: { sortBy?: "usdcF const { data: rateSnapshot, dataUpdatedAt: rateDataUpdatedAt } = useReadRatePreviewerSnapshot({ address: ratePreviewerAddress, + chainId: chain.id, }); const { data: markets, isPending: isMarketsPending } = useReadPreviewerExactly({ address: previewerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: !!account }, }); diff --git a/src/utils/useSimulateProposal.ts b/src/utils/useSimulateProposal.ts index af33a18c0..15005f42f 100644 --- a/src/utils/useSimulateProposal.ts +++ b/src/utils/useSimulateProposal.ts @@ -15,7 +15,7 @@ import { } from "viem"; import { useBytecode, useSimulateContract } from "wagmi"; -import { +import chain, { exaPluginAddress, exaPreviewerAddress, proposalManagerAddress, @@ -188,21 +188,35 @@ export default function useSimulateProposal({ [{ assetOut: proposal.assetOut, minAmountOut: proposal.minAmountOut, route: proposal.route }], ) : proposal.receiver && encodeAbiParameters([{ type: "address" }], [proposal.receiver]); - const { data: deployed } = useBytecode({ address: account, query: { enabled: enabled && !!account } }); + const { data: deployed } = useBytecode({ + address: account, + chainId: chain.id, + query: { enabled: enabled && !!account }, + }); const hasMarket = market !== undefined && market !== zeroAddress; const propose = useSimulateContract({ account, address: account, + chainId: chain.id, functionName: "propose", abi: [...upgradeableModularAccountAbi, ...exaPluginAbi, ...proposalManagerAbi], args: hasMarket ? [market, amount ?? 0n, proposal.proposalType, proposalData ?? "0x"] : undefined, query: { enabled: enabled && !!deployed && !!account && !!amount && hasMarket }, }); - const { data: proposalDelay } = useReadProposalManagerDelay({ address: proposalManagerAddress, query: { enabled } }); - const { data: assets } = useReadExaPreviewerAssets({ address: exaPreviewerAddress, query: { enabled } }); + const { data: proposalDelay } = useReadProposalManagerDelay({ + address: proposalManagerAddress, + chainId: chain.id, + query: { enabled }, + }); + const { data: assets } = useReadExaPreviewerAssets({ + address: exaPreviewerAddress, + chainId: chain.id, + query: { enabled }, + }); const { data: nonce } = useReadProposalManagerQueueNonces({ address: proposalManagerAddress, + chainId: chain.id, args: account ? [account] : undefined, query: { enabled: enabled && !!account }, }); @@ -304,6 +318,7 @@ export default function useSimulateProposal({ const executeProposal = useSimulateContract({ account, address: account, + chainId: chain.id, functionName: "executeProposal", args: [nonce ?? 0n], abi: [...upgradeableModularAccountAbi, ...exaPluginAbi, ...proposalManagerAbi, ...auditorAbi, ...marketAbi], From a974a9d9a9930704c4bb3809eeffc3560a05c30b Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Wed, 11 Mar 2026 18:11:58 -0300 Subject: [PATCH 72/84] =?UTF-8?q?=F0=9F=91=B7=20github:=20set=20base=20wor?= =?UTF-8?q?kflow=20trigger=20to=20base=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/server-base.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/server-base.yaml b/.github/workflows/server-base.yaml index 9bbadd091..08f81bf91 100644 --- a/.github/workflows/server-base.yaml +++ b/.github/workflows/server-base.yaml @@ -1,6 +1,6 @@ name: server/base on: - push: { branches: [sandbox] } + push: { branches: [base] } jobs: build: uses: ./.github/workflows/server-build.yaml From deddc7cdb04043f54cae4e3b36f9214b0a6f30b8 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Wed, 11 Mar 2026 18:15:41 -0300 Subject: [PATCH 73/84] =?UTF-8?q?=F0=9F=94=A7=20app:=20add=20base=20app=20?= =?UTF-8?q?id=20meta=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/+html.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/+html.tsx b/src/app/+html.tsx index 4865b179b..3e9643faa 100644 --- a/src/app/+html.tsx +++ b/src/app/+html.tsx @@ -25,6 +25,7 @@ export default function HTML({ children }: { children: ReactNode }) { name="fc:miniapp" content={`{"version":"1","imageUrl":"https://assets.exactly.app/miniapp-image.webp","button":{"title":"Get your card","action":{"type":"launch_miniapp","name":"${appMetadata.title}","url":"https://${domain}"}}}`} /> + From fba6703e04647be9b375d3fc1515de09482dc603 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Thu, 12 Mar 2026 09:24:51 -0300 Subject: [PATCH 74/84] =?UTF-8?q?=E2=9C=85=20server:=20handle=20firehose?= =?UTF-8?q?=20startup=20dependency=20on=20merged=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/test/database.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/server/test/database.ts b/server/test/database.ts index 6890ae9a9..6d4476e2a 100644 --- a/server/test/database.ts +++ b/server/test/database.ts @@ -76,6 +76,44 @@ export default async function setup() { let substreamsExited: Promise = Promise.resolve(); let substreamsOutputFlushed: Promise = Promise.resolve(); try { + const warmupController = new AbortController(); + const warmupLog = `${startupLogs}/firehose-warmup.log`; + const warmupOutput = createWriteStream(warmupLog); + const warmup = $({ + cancelSignal: warmupController.signal, + forceKillAfterDelay: 33_333, + env: { ETH_RPC_SHORT_BLOCK_NUMBER_NOTATION: "true" }, + })`fireeth start reader-node,merger --advertise-chain-name=anvil --config-file= --data-dir=node_modules/@exactly/.firehose --reader-node-path=bash --reader-node-arguments=${'-c "\ + fireeth tools poll-rpc-blocks http://localhost:8545 0 | tsx script/firehose.ts"'}`; + const warmupLogWatcher = watchProcessOutput(warmup, warmupOutput, warmupController); + try { + await Promise.race([ + waitOn({ + resources: ["node_modules/@exactly/.firehose/storage/merged-blocks/0000000000.dbin.zst"], // cspell:ignore dbin + timeout: 120_000, + }), + postgresExited.then(() => { + throw new Error("postgres exited waiting fireeth warmup"); + }), + warmupLogWatcher.exit.then(() => { + throw new Error("warmup exited before merged blocks"); + }), + warmupLogWatcher.outputError, + ]); + } catch (error) { + warmupController.abort(); + await warmupLogWatcher.exit.catch(() => undefined); + await warmupLogWatcher.outputFlushed; + const warmupText = await readFile(warmupLog, "utf8").catch(() => ""); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`wait firehose warmup: ${message}\nfirehose:\n${warmupText}`, { cause: error }); + } finally { + warmupLogWatcher.stopWatchingOutput(); + warmupController.abort(); + await warmupLogWatcher.exit.catch(() => undefined); + await warmupLogWatcher.outputFlushed; + } + const firehoseLog = `${startupLogs}/firehose.log`; const firehoseOutput = createWriteStream(firehoseLog); const firehose = $({ From 4d29c6fb31ac21e606128ad5a05c8ed779ccec90 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Fri, 13 Mar 2026 14:25:33 -0300 Subject: [PATCH 75/84] =?UTF-8?q?=E2=9C=A8=20server:=20support=20multiple?= =?UTF-8?q?=20cards=20in=20statement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/soft-trains-dig.md | 5 + server/api/activity.ts | 116 ++++---- server/test/api/activity.test.ts | 20 +- server/test/utils/statement.test.ts | 261 +++++++++++++----- server/utils/Statement.tsx | 410 +++++++++++++++++----------- 5 files changed, 527 insertions(+), 285 deletions(-) create mode 100644 .changeset/soft-trains-dig.md diff --git a/.changeset/soft-trains-dig.md b/.changeset/soft-trains-dig.md new file mode 100644 index 000000000..4b9267b10 --- /dev/null +++ b/.changeset/soft-trains-dig.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ support multiple cards in statement diff --git a/server/api/activity.ts b/server/api/activity.ts index 73a7453c9..9324dd352 100644 --- a/server/api/activity.ts +++ b/server/api/activity.ts @@ -1,7 +1,7 @@ import { renderToBuffer } from "@react-pdf/renderer"; import { captureException, setUser } from "@sentry/node"; -import { and, arrayOverlaps, eq, inArray } from "drizzle-orm"; +import { arrayOverlaps, eq } from "drizzle-orm"; import { Hono } from "hono"; import { accepts } from "hono/accepts"; import { validator as vValidator } from "hono-openapi/valibot"; @@ -51,7 +51,7 @@ import { decodeWithdraw } from "@exactly/common/ProposalType"; import { Address, Hash, type Hex } from "@exactly/common/validation"; import { effectiveRate, WAD } from "@exactly/lib"; -import database, { cards, credentials, transactions as transactionsSchema } from "../database"; +import database, { cards, credentials, transactions } from "../database"; import auth from "../middleware/auth"; import { collectors as cryptomateCollectors } from "../utils/cryptomate"; import { collectors as pandaCollectors } from "../utils/panda"; @@ -90,7 +90,7 @@ export default new Hono().get( columns: { account: true }, with: { cards: { - columns: {}, + columns: { id: true, lastFour: true }, with: { transactions: { columns: { hashes: true, payload: true } } }, limit: ignore("card") || maturity !== undefined ? 0 : undefined, }, @@ -262,32 +262,27 @@ export default new Hono().get( ].map((blockNumber) => publicClient.getBlock({ blockNumber })), ); const timestamps = new Map(blocks.map(({ number: block, timestamp }) => [block, timestamp])); - let statementCards: string[] = []; - let cardPurchases: typeof credential.cards; - if (!ignore("card") && maturity !== undefined && borrows) { - const hashes = borrows - .entries() - .filter(([_, { events }]) => events.some(({ maturity: m }) => Number(m) === maturity)) - .map(([hash]) => hash) - .toArray(); - const userCards = await database.query.cards - .findMany({ columns: { id: true }, where: eq(cards.credentialId, credentialId) }) - .then((rows) => rows.map(({ id }) => id)); - const statementTransactions = - hashes.length === 0 || userCards.length === 0 - ? [] - : await database.query.transactions.findMany({ - where: and( - arrayOverlaps(transactionsSchema.hashes, hashes), - inArray(transactionsSchema.cardId, userCards), - ), - columns: { cardId: true, hashes: true, payload: true }, + const purchases = + !ignore("card") && borrows && maturity !== undefined + ? await (() => { + const hashes = borrows + .entries() + .filter(([_, { events }]) => events.some(({ maturity: m }) => Number(m) === maturity)) + .map(([hash]) => hash) + .toArray(); + if (hashes.length === 0) return []; + return database.query.cards.findMany({ + where: eq(cards.credentialId, credentialId), + columns: { id: true, lastFour: true }, + with: { + transactions: { + columns: { hashes: true, payload: true }, + where: arrayOverlaps(transactions.hashes, hashes), + }, + }, }); - statementCards = [...new Set(statementTransactions.map(({ cardId }) => cardId))]; - cardPurchases = [{ transactions: statementTransactions }]; - } else { - cardPurchases = credential.cards; - } + })() + : credential.cards; const accept = accepts(c, { header: "Accept", @@ -297,8 +292,8 @@ export default new Hono().get( const pdf = accept === "application/pdf"; const response = [ - ...cardPurchases.flatMap(({ transactions }) => - transactions.map(({ hashes, payload }) => { + ...purchases.flatMap(({ transactions: txs }) => + txs.map(({ hashes, payload }) => { const panda = safeParse(PandaActivity, { ...(payload as object), hashes, @@ -426,20 +421,16 @@ export default new Hono().get( .toSorted((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id.localeCompare(a.id)); if (maturity !== undefined && pdf) { - if (statementCards.length > 1) return c.json({ code: "multiple cards" }, 400); - const statementCurrency = market(marketUSDCAddress).symbol; - const card = - statementCards.length === 0 - ? undefined - : await database.query.cards.findFirst({ - columns: { lastFour: true }, - where: and(eq(cards.credentialId, credentialId), inArray(cards.id, statementCards)), - }); - const statement = { - maturity, - lastFour: card?.lastFour ?? "", - data: response.flatMap((item): Parameters[0]["data"] => { + const cardLookup = new Map( + purchases.flatMap(({ id, transactions: txs }) => + txs.flatMap(({ hashes }) => hashes.map((hash) => [hash, id] as const)), + ), + ); + const purchasesByCard = Map.groupBy( + response.flatMap((item) => { if (item.type === "panda") { + const cardId = item.operations[0] && cardLookup.get(item.operations[0].transactionHash); + if (!cardId) return []; const installments = item.operations .reduce((accumulator, operation) => { if ("borrow" in operation) { @@ -473,6 +464,7 @@ export default new Hono().get( if (installments.length === 0) return []; return [ { + cardId, id: item.id, timestamp: item.timestamp, description: `${item.merchant.name}${item.merchant.city ? `, ${item.merchant.city}` : ""}`, @@ -481,6 +473,8 @@ export default new Hono().get( ]; } if (item.type === "card" && "borrow" in item) { + const cardId = cardLookup.get(item.transactionHash); + if (!cardId) return []; if ("installments" in item.borrow) { const events = borrows?.get(item.transactionHash)?.events; if (!events) return []; @@ -493,6 +487,7 @@ export default new Hono().get( if (installments.length === 0) return []; return [ { + cardId, id: item.id, timestamp: item.timestamp, description: `${item.merchant.name}${item.merchant.city ? `, ${item.merchant.city}` : ""}`, @@ -504,6 +499,7 @@ export default new Hono().get( if (!borrow || Number(borrow.maturity) !== maturity) return []; return [ { + cardId, id: item.id, timestamp: item.timestamp, description: `${item.merchant.name}${item.merchant.city ? `, ${item.merchant.city}` : ""}`, @@ -511,20 +507,30 @@ export default new Hono().get( }, ]; } - if (item.type === "repay") { - if (item.currency !== statementCurrency) return []; - return [ - { - id: item.id, - timestamp: item.timestamp, - currency: item.currency, - positionAmount: item.positionAmount, - amount: item.amount, - }, - ]; - } return []; }), + ({ cardId }) => cardId, + ); + const statement = { + account: `${account.slice(0, 6)}...${account.slice(-6)}`, + maturity, + cards: purchases + .filter(({ id }) => purchasesByCard.has(id)) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .map(({ id, lastFour }) => ({ + id, + lastFour, + purchases: (purchasesByCard.get(id) ?? []).map(({ cardId: _, ...rest }) => rest), + })), + payments: response + .filter((item) => item.type === "repay") + .filter((repay) => repay.currency === market(marketUSDCAddress).symbol) + .map(({ id, timestamp, amount, positionAmount }) => ({ + id, + timestamp, + amount, + positionAmount, + })), }; return c.body(new Uint8Array(await renderToBuffer(Statement(statement))), 200, { "content-type": "application/pdf", diff --git a/server/test/api/activity.test.ts b/server/test/api/activity.test.ts index 6c46b4dc2..8ab87c4ae 100644 --- a/server/test/api/activity.test.ts +++ b/server/test/api/activity.test.ts @@ -69,7 +69,10 @@ describe.concurrent("authenticated", () => { let maturity: string; beforeAll(async () => { - await database.insert(cards).values([{ id: "activity", credentialId: "bob", lastFour: "1234" }]); + await database.insert(cards).values([ + { id: "first-activity-card", credentialId: "bob", lastFour: "1234" }, + { id: "second-activity-card", credentialId: "bob", lastFour: "6789" }, + ]); const borrows = await anvilClient.getContractEvents({ abi: marketAbi, eventName: "BorrowAtMaturity", @@ -148,7 +151,7 @@ describe.concurrent("authenticated", () => { }; return { id: String(index), - cardId: "activity", + cardId: index === 0 ? "first-activity-card" : "second-activity-card", hashes, payload, hash, @@ -218,7 +221,7 @@ describe.concurrent("authenticated", () => { it("reports bad transaction", async () => { await database .insert(transactions) - .values([{ id: "bad-transaction", cardId: "activity", hashes: ["0x1"], payload: {} }]); + .values([{ id: "bad-transaction", cardId: "first-activity-card", hashes: ["0x1"], payload: {} }]); const response = await appClient.index.$get( { query: { include: "card" } }, { headers: { "test-credential-id": "bob" } }, @@ -251,6 +254,17 @@ describe.concurrent("authenticated", () => { expect(json.every((item) => !item.borrow || item.borrow.maturity === Number(maturity))).toBe(true); }); + it("returns empty card activity for unmatched maturity", async () => { + expect.hasAssertions(); + const response = await appClient.index.$get( + { query: { include: "card", maturity: "0" } }, + { headers: { "test-credential-id": "bob" } }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual([]); + }); + it("returns statement pdf", async () => { expect.hasAssertions(); const response = await appClient.index.$get( diff --git a/server/test/utils/statement.test.ts b/server/test/utils/statement.test.ts index 9fc9d7f5c..fa84ec073 100644 --- a/server/test/utils/statement.test.ts +++ b/server/test/utils/statement.test.ts @@ -1,132 +1,251 @@ import { renderToBuffer } from "@react-pdf/renderer"; import { isValidElement, type ReactNode } from "react"; -import { describe, expect, it } from "vitest"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; import { MATURITY_INTERVAL } from "@exactly/lib"; import Statement, { format } from "../../utils/Statement"; +const directory = path.join("node_modules/@exactly/.runtime"); + describe("statement rendering", () => { + beforeAll(async () => { + await mkdir(directory, { recursive: true }); + }); it("renders with purchases", async () => { const statement = { - data: [ + account: "0x92bD...e82BA8", + cards: [ { - id: "purchase-1", - description: "grocery store", - installments: [{ amount: 50.25, current: 1, total: 3 }], - timestamp: "2025-12-19T11:35:11.030Z", - }, - { - id: "purchase-2", - description: "gas station", - installments: [{ amount: 30.5, current: 2, total: 2 }], - timestamp: "2025-12-19T11:22:49.412Z", + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "grocery store", + installments: [{ amount: 50.25, current: 1, total: 3 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + { + id: "purchase-2", + description: "gas station", + installments: [{ amount: 30.5, current: 2, total: 2 }], + timestamp: "2025-12-19T11:22:49.412Z", + }, + ], }, ], - lastFour: "1234", maturity: 1_768_435_200, + payments: [], }; const pdf = await renderToBuffer(Statement(statement)); expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-purchases-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact const text = collectText(Statement(statement)); - expect(text).toContain("Exa App"); - expect(text).toContain("Card Statement"); - expect(text).toContain("1768435200"); - expect(text).toContain("**** **** **** 1234"); - expect(text).toContain(format(1_768_435_200)); - expect(text).toContain(format(1_768_435_200 - MATURITY_INTERVAL)); - expect(text).toContain("Purchases"); + expect(text).toContain("Statement"); + expect(text).toContain("Account 0x92bD...e82BA8"); + expect(text).toContain("Card **** 1234"); + expect(text).toContain(format(new Date(1_768_435_200 * 1000))); + expect(text).toContain(format(new Date((1_768_435_200 - MATURITY_INTERVAL) * 1000))); expect(text).toContain("grocery store"); - expect(text).toContain("Installment 1 of 3"); - expect(text).toContain("USDC 50.25"); + expect(text).toContain("$50.25"); expect(text).toContain("gas station"); - expect(text).toContain("Installment 2 of 2"); - expect(text).toContain("USDC 30.50"); + expect(text).toContain("$30.50"); + expect(text).toContain("Summary"); + expect(text).toContain("Due balance"); }); - it("renders with repayments", async () => { + it("renders with payments", async () => { const statement = { - data: [ + account: "0x92bD...e82BA8", + cards: [ { - id: "repay-1", - amount: 100, - currency: "USDC", - positionAmount: 105.82, - timestamp: "2025-12-19T11:35:11.030Z", + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "coffee shop", + installments: [{ amount: 100, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], }, ], - lastFour: "1234", maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 100, positionAmount: 100, timestamp: "2025-12-19T11:35:11.030Z" }], }; const pdf = await renderToBuffer(Statement(statement)); expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-payments-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact const text = collectText(Statement(statement)); - expect(text).toContain("Exa App"); - expect(text).toContain("Card Statement"); - expect(text).toContain("1768435200"); - expect(text).toContain("**** **** **** 1234"); - expect(text).toContain(format(1_768_435_200)); - expect(text).toContain(format(1_768_435_200 - MATURITY_INTERVAL)); + expect(text).toContain("Statement"); + expect(text).toContain("Account 0x92bD...e82BA8"); + expect(text).toContain("Card **** 1234"); + expect(text).toContain(format(new Date(1_768_435_200 * 1000))); + expect(text).toContain(format(new Date((1_768_435_200 - MATURITY_INTERVAL) * 1000))); expect(text).toContain("Payments"); - expect(text).toContain("5.50% discount applied"); - expect(text).toContain("USDC 100.00"); + expect(text).toContain("$100.00"); }); it("renders with empty data", async () => { const statement = { - data: [], - lastFour: "", + account: "0x92bD...e82BA8", + cards: [], maturity: 1_768_435_200, + payments: [], }; const pdf = await renderToBuffer(Statement(statement)); expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-empty-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact const text = collectText(Statement(statement)); - expect(text).toContain("Exa App"); - expect(text).toContain("Card Statement"); - expect(text).toContain("1768435200"); - expect(text).toContain(format(1_768_435_200)); - expect(text).toContain(format(1_768_435_200 - MATURITY_INTERVAL)); + expect(text).toContain("Statement"); + expect(text).toContain("Account 0x92bD...e82BA8"); + expect(text).toContain(format(new Date(1_768_435_200 * 1000))); + expect(text).toContain(format(new Date((1_768_435_200 - MATURITY_INTERVAL) * 1000))); + expect(text).toContain("Summary"); + expect(text).toContain("Due balance"); }); - it("renders with both purchases and repayments", async () => { + it("renders with multiple cards", async () => { const statement = { - data: [ + account: "0x92bD...e82BA8", + cards: [ { - id: "purchase-3", - description: "online purchase", - installments: [{ amount: 75, current: 1, total: 1 }], - timestamp: "2025-12-19T11:35:11.030Z", + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "grocery store", + installments: [{ amount: 50, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], }, { - id: "repay-2", - amount: 200, - currency: "USDC", - positionAmount: 206.6, - timestamp: "2025-12-20T10:00:00.000Z", + id: "card-2", + lastFour: "5678", + purchases: [ + { + id: "purchase-2", + description: "online purchase", + installments: [{ amount: 75, current: 1, total: 1 }], + timestamp: "2025-12-19T11:22:49.412Z", + }, + ], }, ], - lastFour: "5678", maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 25, positionAmount: 25, timestamp: "2025-12-20T10:00:00.000Z" }], }; const pdf = await renderToBuffer(Statement(statement)); expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-multiple-cards-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact const text = collectText(Statement(statement)); - expect(pdf.byteLength).toBeGreaterThan(0); - expect(text).toContain("Exa App"); - expect(text).toContain("Card Statement"); - expect(text).toContain("1768435200"); - expect(text).toContain("**** **** **** 5678"); - expect(text).toContain(format(1_768_435_200)); - expect(text).toContain(format(1_768_435_200 - MATURITY_INTERVAL)); - expect(text).toContain("Purchases"); + expect(text).toContain("Statement"); + expect(text).toContain("Account 0x92bD...e82BA8"); + expect(text).toContain("Card **** 1234"); + expect(text).toContain("Card **** 5678"); + expect(text).toContain(format(new Date(1_768_435_200 * 1000))); + expect(text).toContain(format(new Date((1_768_435_200 - MATURITY_INTERVAL) * 1000))); + expect(text).toContain("grocery store"); + expect(text).toContain("$50.00"); expect(text).toContain("online purchase"); - expect(text).toContain("Installment 1 of 1"); - expect(text).toContain("USDC 75.00"); + expect(text).toContain("$75.00"); + expect(text).toContain("Summary"); + expect(text).toContain("Due balance"); expect(text).toContain("Payments"); - expect(text).toContain("% discount applied"); - expect(text).toContain("USDC 200.00"); + expect(text).toContain("$25.00"); + }); + + it("renders discount chip for early payment", async () => { + const statement = { + account: "0x92bD...e82BA8", + cards: [ + { + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "coffee shop", + installments: [{ amount: 105.82, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], + }, + ], + maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 100, positionAmount: 105.82, timestamp: "2025-12-19T11:35:11.030Z" }], + }; + const pdf = await renderToBuffer(Statement(statement)); + expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-discount-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact + const text = collectText(Statement(statement)); + expect(text).toContain("5.50% discount"); + expect(text).not.toContain("penalty"); + expect(text).toContain("$0.00"); + }); + + it("renders penalty chip for late payment", async () => { + const statement = { + account: "0x92bD...e82BA8", + cards: [ + { + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "coffee shop", + installments: [{ amount: 100, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], + }, + ], + maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 102.31, positionAmount: 100, timestamp: "2025-12-19T11:35:11.030Z" }], + }; + const pdf = await renderToBuffer(Statement(statement)); + expect(pdf.byteLength).toBeGreaterThan(0); + await writeFile(path.join(directory, `statement-penalty-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact + const text = collectText(Statement(statement)); + expect(text).toContain("2.31% penalty"); + expect(text).not.toContain("discount"); + expect(text).toContain("$0.00"); + }); + + it("renders no chip when amount equals positionAmount", async () => { + const statement = { + account: "0x92bD...e82BA8", + cards: [ + { + id: "card-1", + lastFour: "1234", + purchases: [ + { + id: "purchase-1", + description: "coffee shop", + installments: [{ amount: 100, current: 1, total: 1 }], + timestamp: "2025-12-19T11:35:11.030Z", + }, + ], + }, + ], + maturity: 1_768_435_200, + payments: [{ id: "repay-1", amount: 100, positionAmount: 100, timestamp: "2025-12-19T11:35:11.030Z" }], + }; + const pdf = await renderToBuffer(Statement(statement)); + await writeFile(path.join(directory, `statement-no-chip-${Date.now()}.pdf`), new Uint8Array(pdf)); // eslint-disable-line security/detect-non-literal-fs-filename -- test artifact + const text = collectText(Statement(statement)); + expect(text).not.toContain("discount"); + expect(text).not.toContain("penalty"); }); }); diff --git a/server/utils/Statement.tsx b/server/utils/Statement.tsx index ee6b1d393..d3ad911c9 100644 --- a/server/utils/Statement.tsx +++ b/server/utils/Statement.tsx @@ -1,145 +1,208 @@ -import { Document, Page, StyleSheet, Text, View } from "@react-pdf/renderer"; +import { Document, G, Page, Path, Rect, StyleSheet, Svg, Text, View } from "@react-pdf/renderer"; import React from "react"; import { MATURITY_INTERVAL } from "@exactly/lib"; const Statement = ({ - data, - lastFour, + account, + cards, maturity, + payments, }: { - data: ( - | { - amount: number; - currency: string; - id: string; - positionAmount: number; - timestamp: string; - } - | { - description: string; - id: string; - installments: { amount: number; current: number; total: number }[]; - timestamp: string; - } - )[]; - lastFour: string; - maturity: number; -}) => { - const dueDate = format(maturity); - const statementDate = format(maturity - MATURITY_INTERVAL); - const repayments = data.filter( - ( - item, - ): item is { - amount: number; - currency: string; - id: string; - positionAmount: number; - timestamp: string; - } => "positionAmount" in item, - ); - const purchases = data.filter( - ( - item, - ): item is { + account: string; + cards: { + id: string; + lastFour: string; + purchases: { description: string; id: string; installments: { amount: number; current: number; total: number }[]; timestamp: string; - } => "description" in item, + }[]; + }[]; + maturity: number; + payments: { amount: number; id: string; positionAmount: number; timestamp: string }[]; +}) => { + const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); + + const totalSpent = cards.reduce( + (sum, card) => + sum + + card.purchases.reduce((s, p) => s + p.installments.reduce((a, installment) => a + installment.amount, 0), 0), + 0, ); + const totalPayments = payments.reduce((sum, p) => sum + p.positionAmount, 0); + const dueBalance = totalSpent - totalPayments; return ( - Exa App - Card Statement + + + + + + + + + + + + + + + + Statement + + Account + {account} + + + Date + {format(new Date((maturity - MATURITY_INTERVAL) * 1000))} + - - - - Id: - {maturity} - - - Date: - {statementDate} - + + + Period + + {format(new Date((maturity - MATURITY_INTERVAL) * 1000))} to {format(new Date(maturity * 1000))} + - - - Card No.: - **** **** **** {lastFour} - - - Due date: - {dueDate} + + Due date + {format(new Date(maturity * 1000))} + + + Total spent + {currency.format(totalSpent)} + + + + Summary + {cards.map((card) => { + const cardTotal = card.purchases.reduce( + (sum, p) => sum + p.installments.reduce((a, installment) => a + installment.amount, 0), + 0, + ); + return ( + + Card **** {card.lastFour} purchases + + {currency.format(cardTotal)} + + ); + })} + {payments.length > 0 && ( + + Payments + + -{currency.format(totalPayments)} + )} + + Due balance + {currency.format(dueBalance)} - {repayments.length > 0 && ( - <> + {cards.map((card) => { + const cardTotal = card.purchases.reduce( + (sum, p) => sum + p.installments.reduce((a, installment) => a + installment.amount, 0), + 0, + ); + return ( + + Card **** {card.lastFour} purchases + + DATE + DESCRIPTION + INSTALLMENTS + TOTAL + + {card.purchases.map((purchase) => ( + + {format(new Date(purchase.timestamp))} + {purchase.description} + + {purchase.installments + .map((installment) => `${installment.current} of ${installment.total}`) + .join(", ")} + + + {currency.format(purchase.installments.reduce((sum, installment) => sum + installment.amount, 0))} + + + ))} + + + Total spent on card **** {card.lastFour} + + {currency.format(cardTotal)} + + + ); + })} + {payments.length > 0 && ( + Payments - {repayments.map((item) => { + + DATE + DESCRIPTION + TOTAL + + {payments.map((payment) => { const percent = - item.positionAmount === 0 ? 0 : ((item.positionAmount - item.amount) / item.positionAmount) * 100; + payment.positionAmount === 0 + ? 0 + : ((payment.positionAmount - payment.amount) / payment.positionAmount) * 100; return ( - - - - {new Date(item.timestamp).toISOString().slice(0, 10)} - {percent !== 0 && ( - - 0 ? styles.discountChip : styles.penaltyChip}> - 0 ? styles.discountText : styles.penaltyText}> - {Math.abs(percent).toFixed(2)}% {percent > 0 ? "discount" : "penalty"} applied - - - - )} - - - - {item.currency} {item.amount.toFixed(2)} - - + + {format(new Date(payment.timestamp))} + + Payment + {percent >= 0.01 && ( + + {percent.toFixed(2)}% discount + + )} + {percent <= -0.01 && ( + + {Math.abs(percent).toFixed(2)}% penalty + + )} + -{currency.format(payment.positionAmount)} ); })} - - )} - {purchases.length > 0 && ( - <> - Purchases - {purchases.map((item) => ( - - - - {new Date(item.timestamp).toISOString().slice(0, 10)} - {item.description} - {item.installments.map((installment) => { - const { current, total } = installment; - return ( - - Installment {current} of {total} - - ); - })} - - - - USDC {item.installments.reduce((sum, inst) => sum + inst.amount, 0).toFixed(2)} - - - - - ))} - + + + Payments total + -{currency.format(totalPayments)} + + )} + + Due balance + {currency.format(dueBalance)} + ); @@ -147,8 +210,8 @@ const Statement = ({ export default Statement; -export function format(timestamp: number) { - return new Date(timestamp * 1000).toISOString().slice(0, 10); +export function format(date: Date) { + return date.toLocaleDateString("en-US", { timeZone: "UTC", month: "short", day: "2-digit", year: "numeric" }); } const styles = StyleSheet.create({ @@ -157,61 +220,96 @@ const styles = StyleSheet.create({ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", - marginBottom: 12, + marginBottom: 16, paddingBottom: 16, borderBottom: "1px solid #E6E9E8", }, headerLeft: { flex: 1 }, - appTitle: { fontSize: 24, fontWeight: "bold", color: "#1A211E", marginBottom: 4 }, - title: { fontSize: 18, fontWeight: "600", color: "#1A211E" }, - discountChipContainer: { flexDirection: "row", alignItems: "center", gap: 6 }, - discountChip: { - backgroundColor: "#E0F8F3", - paddingHorizontal: 6, - paddingVertical: 2, + headerRight: { alignItems: "flex-end" }, + title: { fontSize: 18, fontWeight: "600", color: "#1A211E", marginBottom: 4 }, + headerDetail: { fontSize: 11, color: "#5F6563", marginBottom: 2 }, + headerLabel: { fontWeight: "bold", color: "#5F6563" }, + infoBar: { + flexDirection: "row", + marginBottom: 16, borderRadius: 8, - alignSelf: "flex-start", + border: "1px solid #EEF1F0", + backgroundColor: "#F3F5F4", }, - discountText: { fontSize: 10, color: "#008573", fontWeight: "500" }, - penaltyChip: { - backgroundColor: "#FDE8EA", - paddingHorizontal: 6, - paddingVertical: 2, + infoCell: { flex: 1, padding: 12 }, + infoCellBorder: { flex: 1, padding: 12, borderLeft: "1px solid #EEF1F0" }, + infoLabel: { fontSize: 10, color: "#5F6563", marginBottom: 4 }, + infoValue: { fontSize: 12, color: "#1A211E" }, + summaryBox: { + marginBottom: 16, + paddingTop: 16, + paddingHorizontal: 16, borderRadius: 8, - alignSelf: "flex-start", - }, - penaltyText: { fontSize: 10, color: "#C03445", fontWeight: "500" }, - sectionHeader: { - fontSize: 16, - fontWeight: "bold", - color: "#1A211E", - marginTop: 8, - marginBottom: 8, - paddingLeft: 4, + border: "1px solid #EEF1F0", + backgroundColor: "#FFFFFF", }, - activityItem: { + summaryTitle: { fontSize: 14, fontWeight: "bold", color: "#1A211E", marginBottom: 8 }, + summaryRow: { flexDirection: "row", alignItems: "flex-end", marginBottom: 4 }, + summaryLeader: { flex: 1, borderBottom: "1px dotted #C0C0C0", marginHorizontal: 4, marginBottom: 3 }, + summaryLabel: { fontSize: 12, color: "#1A211E" }, + summaryAmount: { fontSize: 12, color: "#1A211E" }, + summaryDueRow: { flexDirection: "row", - alignItems: "center", - backgroundColor: "#FFFFFF", + justifyContent: "space-between", padding: 16, - marginBottom: 8, - borderRadius: 8, - border: "1px solid #EEF1F0", + marginHorizontal: -16, + backgroundColor: "#F3F5F4", + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + marginTop: 12, + }, + section: { marginBottom: 16 }, + sectionHeader: { fontSize: 14, fontWeight: "bold", color: "#1A211E", marginBottom: 8 }, + tableHeader: { + flexDirection: "row", + paddingVertical: 6, + paddingHorizontal: 8, + borderBottom: "1px solid #E6E9E8", + marginBottom: 4, + fontSize: 11, + color: "#5F6563", + }, + tableRow: { + flexDirection: "row", + paddingVertical: 8, + paddingHorizontal: 8, }, - contentContainer: { flex: 1, marginRight: 16 }, - primaryText: { fontSize: 15, fontWeight: "600", color: "#1A211E", marginBottom: 4 }, - amountContainer: { alignItems: "flex-end" }, - amountText: { fontSize: 15, fontWeight: "600", color: "#1A211E", marginBottom: 4 }, - installmentText: { fontSize: 13, color: "#5F6563", marginBottom: 4 }, - infoSection: { + colDate: { width: 100, fontSize: 11, color: "#5F6563" }, + colDesc: { flex: 1, fontSize: 11, color: "#1A211E" }, + colDescRow: { flex: 1, flexDirection: "row", alignItems: "center", gap: 4 }, + descText: { fontSize: 11, color: "#1A211E" }, + discountChip: { backgroundColor: "#E6F4EA", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }, + discountText: { fontSize: 9, color: "#1B7D3A" }, + penaltyChip: { backgroundColor: "#FDE8E8", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }, + penaltyText: { fontSize: 9, color: "#C5221F" }, + colInst: { width: 100, fontSize: 11, color: "#1A211E", textAlign: "center" }, + colTotal: { width: 90, fontSize: 11, color: "#1A211E", textAlign: "right" }, + headerDate: { width: 100, fontSize: 11, color: "#5F6563" }, + headerDesc: { flex: 1, fontSize: 11, color: "#5F6563" }, + headerInst: { width: 100, fontSize: 11, color: "#5F6563", textAlign: "center" }, + headerTotal: { width: 90, fontSize: 11, color: "#5F6563", textAlign: "right" }, + totalRow: { + flexDirection: "row", + paddingVertical: 8, + paddingHorizontal: 8, + borderTop: "1px solid #E6E9E8", + marginTop: 4, + }, + totalLabel: { flex: 1, fontSize: 11, color: "#1A211E" }, + totalAmount: { width: 90, fontSize: 11, color: "#1A211E", textAlign: "right" }, + dueBar: { flexDirection: "row", justifyContent: "space-between", - marginBottom: 16, - paddingBottom: 12, - borderBottom: "1px solid #EEF1F0", + padding: 16, + backgroundColor: "#1A211E", + borderRadius: 8, + marginTop: 8, }, - infoColumn: { flex: 1 }, - infoRow: { flexDirection: "row", marginBottom: 6 }, - infoLabel: { fontSize: 13, fontWeight: "600", color: "#5F6563", width: 80 }, - cardNumber: { fontSize: 13, color: "#1A211E", flex: 1, fontFamily: "Courier" }, + dueLabel: { fontSize: 14, fontWeight: "bold", color: "#FFFFFF" }, + dueAmount: { fontSize: 14, fontWeight: "bold", color: "#FFFFFF" }, }); From 6c16fa85053cc4bf1367847009f5f930a156a6cb Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 26 Mar 2026 10:37:45 -0300 Subject: [PATCH 76/84] =?UTF-8?q?=E2=9C=A8=20server:=20forward=20exchange?= =?UTF-8?q?=20rate=20to=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/shy-foxes-trade.md | 5 +++ docs/src/content/docs/webhooks.md | 2 ++ server/hooks/panda.ts | 11 ++++++ server/test/hooks/panda.test.ts | 60 ++++++++++++++++++++++++++++--- 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 .changeset/shy-foxes-trade.md diff --git a/.changeset/shy-foxes-trade.md b/.changeset/shy-foxes-trade.md new file mode 100644 index 000000000..9a0a6491e --- /dev/null +++ b/.changeset/shy-foxes-trade.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward exchange rate to webhooks diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index 161220131..f4d5328d0 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -553,6 +553,7 @@ The onchain receipt will be present only if a onchain transaction is necessary. | body.spend.authorizedAmount | integer | The authorized amount | 100 | | body.spend.status | "pending" \| "declined" | Can be pending or declined. In case of declined, the field `declinedReason` has the reason | pending | | body.spend.declinedReason? | string | Decline message | webhook declined | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | ### Transaction updated event @@ -620,6 +621,7 @@ if an onchain transaction is necessary. | body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | | body.spend.enrichedMerchantName? | string | name of the enriched merchant | Jockey | | body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Shopping | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | ### User updated diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index e09c00795..95a00bbef 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -107,6 +107,7 @@ const Transaction = v.variant("action", [ ...BaseTransaction.entries.spend.entries, status: v.picklist(["pending", "declined"]), declinedReason: v.optional(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -156,6 +157,7 @@ const Transaction = v.variant("action", [ enrichedMerchantIcon: v.nullish(v.string()), enrichedMerchantName: v.nullish(v.string()), enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -1317,6 +1319,13 @@ async function publish(payload: v.InferOutput, receipt?: Transac ...payload, ...(receipt && { receipt }), timestamp, + ...(payload.action !== "updated" && + payload.body.spend.currency !== payload.body.spend.localCurrency && { + body: { + ...payload.body, + spend: { ...payload.body.spend, exchangeRate: payload.body.spend.exchangeRate }, + }, + }), }), webhook.transaction?.[payload.action] ?? webhook.url, webhook.secret, @@ -1371,6 +1380,7 @@ const Webhook = v.variant("resource", [ ...BaseWebhook.entries.spend.entries, status: v.picklist(["pending", "declined"]), declinedReason: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -1409,6 +1419,7 @@ const Webhook = v.variant("resource", [ enrichedMerchantIcon: v.nullish(v.string()), enrichedMerchantName: v.nullish(v.string()), enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 9b8fc9415..dd30667d2 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2166,7 +2166,7 @@ describe("webhooks", () => { afterEach(() => vi.resetAllMocks()); - it("forwards transaction created", async () => { + it("forwards transaction created with exchangeRate", async () => { const cardId = `${webhookAccount}-card`; const fetch = globalThis.fetch; let publish = false; @@ -2190,6 +2190,9 @@ describe("webhooks", () => { cardId, userId: webhookAccount, amount: 100, + localAmount: 85, + localCurrency: "eur", + exchangeRate: 1.176_470_588_2, authorizedAt: new Date().toISOString(), }, }, @@ -2198,11 +2201,48 @@ describe("webhooks", () => { await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.176_470_588_2 } }, + }); + }); + + it("forwards transaction created without exchangeRate when same currency", async () => { + const cardId = `${webhookAccount}-card`; + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: "same-currency-tx", + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); }); - it("forwards transaction updated", async () => { + it("forwards transaction updated without exchangeRate", async () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; @@ -2227,6 +2267,8 @@ describe("webhooks", () => { ...transactionUpdated.json.body.spend, cardId, userId: webhookAccount, + localCurrency: "eur", + localAmount: 6800, authorizedAt: new Date().toISOString(), status: "pending", authorizationUpdateAmount: 98, @@ -2238,11 +2280,11 @@ describe("webhooks", () => { await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); }); - it("forwards transaction completed", async () => { + it("forwards transaction completed with exchangeRate", async () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; @@ -2267,6 +2309,9 @@ describe("webhooks", () => { cardId, userId: webhookAccount, amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, authorizedAt: new Date().toISOString(), }, }, @@ -2287,6 +2332,9 @@ describe("webhooks", () => { postedAt: new Date().toISOString(), status: "completed", amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, authorizedAmount: 99, }, }, @@ -2296,8 +2344,10 @@ describe("webhooks", () => { await vi.waitUntil(() => publishCounter > 1, 60_000); const options = mockFetch.mock.calls.filter(([url]) => url === "https://exa.test")[1]?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.178_571_428_6 } }, + }); }); it("forwards card updated active", async () => { From 8d9b97589690dcbafe6e5433044ce567237e8394 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 26 Mar 2026 15:55:56 -0300 Subject: [PATCH 77/84] =?UTF-8?q?=F0=9F=94=8A=20server:=20fix=20multiline?= =?UTF-8?q?=20webhook=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/hooks/panda.ts | 6 +++--- server/test/hooks/panda.test.ts | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 95a00bbef..bbd78977c 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1241,7 +1241,7 @@ async function publish(payload: v.InferOutput, receipt?: Transac }, }, ); - debugWebhook({ + debugWebhook("%j", { code: result.status, response: await result.text().then((text) => { try { @@ -1255,9 +1255,9 @@ async function publish(payload: v.InferOutput, receipt?: Transac } catch (error) { if (error instanceof Error) { if (error.message === "WebhookFailed") { - debugWebhook(error.cause); + debugWebhook("%j", error.cause); } else { - debugWebhook({ error: error.message, payload: webhookPayload }); + debugWebhook("%j", { error: error.message, payload: webhookPayload }); } } throw error; diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index dd30667d2..a71ac62aa 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2452,7 +2452,7 @@ describe("webhooks", () => { }); await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); - expect(webhookLogger).toHaveBeenCalledWith(expect.objectContaining({ response: "OK" })); + expect(webhookLogger).toHaveBeenCalledWith("%j", expect.objectContaining({ response: "OK" })); }); it("logs json on webhook ok response", async () => { @@ -2475,7 +2475,10 @@ describe("webhooks", () => { }); await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); - expect(webhookLogger).toHaveBeenCalledWith(expect.objectContaining({ response: { status: 200, message: "OK" } })); + expect(webhookLogger).toHaveBeenCalledWith( + "%j", + expect.objectContaining({ response: { status: 200, message: "OK" } }), + ); }); }); From 386594e9321a8e037371cf45ef23c7ab690648c7 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Mon, 16 Mar 2026 23:08:29 -0300 Subject: [PATCH 78/84] =?UTF-8?q?=E2=9C=85=20contracts:=20provision=20mult?= =?UTF-8?q?icall3=20in=20anvil=20environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/.gas-snapshot | 20 ++++++++++---------- contracts/script/ExaAccountFactory.s.sol | 2 +- contracts/test/Fork.t.sol | 19 ++++++++++++++----- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/contracts/.gas-snapshot b/contracts/.gas-snapshot index bb916365f..c9aea82d6 100644 --- a/contracts/.gas-snapshot +++ b/contracts/.gas-snapshot @@ -1,5 +1,5 @@ -ExaAccountFactoryTest:testFuzz_createAccount_EOAOwners(uint256,address[63]) (runs: 256, μ: 5853309, ~: 5347535) -ExaAccountFactoryTest:test_deploy_deploysToSameAddress() (gas: 13989314) +ExaAccountFactoryTest:testFuzz_createAccount_EOAOwners(uint256,address[63]) (runs: 256, μ: 5857216, ~: 5360703) +ExaAccountFactoryTest:test_deploy_deploysToSameAddress() (gas: 14972464) ExaPluginTest:testFork_claimAndVestEscrowedEXA_claimsAndVests() (gas: 38664724) ExaPluginTest:testFork_collectCollateral_collects() (gas: 32167249) ExaPluginTest:testFork_crossRepay_repays() (gas: 33988652) @@ -212,14 +212,14 @@ IssuerCheckerTest:test_setPrevIssuerWindow_emits_PrevIssuerWindowSet() (gas: 526 IssuerCheckerTest:test_setPrevIssuerWindow_reverts_whenNotAdmin() (gas: 45548) MockSwapperTest:test_swapExactAmountIn_swaps() (gas: 269807) MockSwapperTest:test_swapExactAmountOut_swaps() (gas: 269803) -RedeployerTest:test_deployEXA_deploysAtSameAddress_onBase() (gas: 56250957) -RedeployerTest:test_deployExaFactory_deploysAtSameAddress_onEthereum() (gas: 273369114) -RedeployerTest:test_deployExaFactory_deploysAtSameAddress_onPolygon() (gas: 368043624) -RedeployerTest:test_deployExaFactory_deploysViaCreate3AtSameAddress_onPolygon() (gas: 45014545) -RedeployerTest:test_prepare_reverts_whenAdminIsDeployer() (gas: 28800535) -RedeployerTest:test_recoversNativeETHOnPolygon() (gas: 45185670) -RedeployerTest:test_run_reverts_whenAttackerUpgradesProxy() (gas: 38475794) -RedeployerTest:test_run_reverts_whenTargetNonceTooLow() (gas: 29265310) +RedeployerTest:test_deployEXA_deploysAtSameAddress_onBase() (gas: 56313588) +RedeployerTest:test_deployExaFactory_deploysAtSameAddress_onEthereum() (gas: 273433983) +RedeployerTest:test_deployExaFactory_deploysAtSameAddress_onPolygon() (gas: 368108493) +RedeployerTest:test_deployExaFactory_deploysViaCreate3AtSameAddress_onPolygon() (gas: 45079414) +RedeployerTest:test_prepare_reverts_whenAdminIsDeployer() (gas: 28865404) +RedeployerTest:test_recoversNativeETHOnPolygon() (gas: 45250539) +RedeployerTest:test_run_reverts_whenAttackerUpgradesProxy() (gas: 38540852) +RedeployerTest:test_run_reverts_whenTargetNonceTooLow() (gas: 29330179) RefunderTest:test_refund_refunds() (gas: 263363) RefunderTest:test_refund_reverts_whenExpired() (gas: 88359) RefunderTest:test_refund_reverts_whenNotKeeper() (gas: 68861) diff --git a/contracts/script/ExaAccountFactory.s.sol b/contracts/script/ExaAccountFactory.s.sol index 360c9ed80..3aa0a6f99 100644 --- a/contracts/script/ExaAccountFactory.s.sol +++ b/contracts/script/ExaAccountFactory.s.sol @@ -42,7 +42,7 @@ contract DeployExaAccountFactory is BaseScript { } function getAddress() external returns (address) { - etchCreate3(); + etchCanonical(); vm.etch(address(0), vm.getDeployedCode("ExaPlugin.sol:ExaPlugin")); return CREATE3_FACTORY.getDeployed(acct("admin"), _salt(IPlugin(address(0)))); } diff --git a/contracts/test/Fork.t.sol b/contracts/test/Fork.t.sol index 237ec21aa..9f19be892 100644 --- a/contracts/test/Fork.t.sol +++ b/contracts/test/Fork.t.sol @@ -25,17 +25,26 @@ abstract contract ForkTest is Test { else if (block.chainid == 84_532) CREATE3_FACTORY = ICreate3Factory(0x9f275F6D25232FFf082082a53C62C6426c1cc94C); else CREATE3_FACTORY = ICreate3Factory(0x93FEC2C00BfE902F733B57c5a6CeeD7CD1384AE1); vm.label(address(CREATE3_FACTORY), "CREATE3Factory"); - if (block.chainid == getChain("anvil").chainId) etchCreate3(); + if (block.chainid == getChain("anvil").chainId) etchCanonical(); } - function etchCreate3() internal { - bytes memory code = + function etchCanonical() internal { + bytes memory create3Code = hex"6080604052600436106100295760003560e01c806350f1c4641461002e578063cdcb760a14610077575b600080fd5b34801561003a57600080fd5b5061004e610049366004610489565b61008a565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b61004e6100853660046104fd565b6100ee565b6040517fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606084901b166020820152603481018290526000906054016040516020818303038152906040528051906020012091506100e78261014c565b9392505050565b6040517fffffffffffffffffffffffffffffffffffffffff0000000000000000000000003360601b166020820152603481018390526000906054016040516020818303038152906040528051906020012092506100e78383346102b2565b604080518082018252601081527f67363d3d37363d34f03d5260086018f30000000000000000000000000000000060209182015290517fff00000000000000000000000000000000000000000000000000000000000000918101919091527fffffffffffffffffffffffffffffffffffffffff0000000000000000000000003060601b166021820152603581018290527f21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f60558201526000908190610228906075015b6040516020818303038152906040528051906020012090565b6040517fd69400000000000000000000000000000000000000000000000000000000000060208201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606083901b1660228201527f010000000000000000000000000000000000000000000000000000000000000060368201529091506100e79060370161020f565b6000806040518060400160405280601081526020017f67363d3d37363d34f03d5260086018f30000000000000000000000000000000081525090506000858251602084016000f5905073ffffffffffffffffffffffffffffffffffffffff811661037d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f4445504c4f594d454e545f4641494c454400000000000000000000000000000060448201526064015b60405180910390fd5b6103868661014c565b925060008173ffffffffffffffffffffffffffffffffffffffff1685876040516103b091906105d6565b60006040518083038185875af1925050503d80600081146103ed576040519150601f19603f3d011682016040523d82523d6000602084013e6103f2565b606091505b50509050808015610419575073ffffffffffffffffffffffffffffffffffffffff84163b15155b61047f576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f494e495449414c495a4154494f4e5f4641494c454400000000000000000000006044820152606401610374565b5050509392505050565b6000806040838503121561049c57600080fd5b823573ffffffffffffffffffffffffffffffffffffffff811681146104c057600080fd5b946020939093013593505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6000806040838503121561051057600080fd5b82359150602083013567ffffffffffffffff8082111561052f57600080fd5b818501915085601f83011261054357600080fd5b813581811115610555576105556104ce565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f0116810190838211818310171561059b5761059b6104ce565b816040528281528860208487010111156105b457600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b6000825160005b818110156105f757602081860181015185830152016105dd565b50600092019182525091905056fea26469706673582212201ff95c2aafa102481fdd22c59ee7f98a92a9662a6566ab5e0498e8bb47a5f30c64736f6c63430008110033"; - vm.etch(address(CREATE3_FACTORY), code); + vm.etch(address(CREATE3_FACTORY), create3Code); + + bytes memory multicall3Code = + hex"6080604052600436106100f35760003560e01c80634d2301cc1161008a578063a8b0574e11610059578063a8b0574e1461025a578063bce38bd714610275578063c3077fa914610288578063ee82ac5e1461029b57600080fd5b80634d2301cc146101ec57806372425d9d1461022157806382ad56cb1461023457806386d516e81461024757600080fd5b80633408e470116100c65780633408e47014610191578063399542e9146101a45780633e64a696146101c657806342cbb15c146101d957600080fd5b80630f28c97d146100f8578063174dea711461011a578063252dba421461013a57806327e86d6e1461015b575b600080fd5b34801561010457600080fd5b50425b6040519081526020015b60405180910390f35b61012d610128366004610a85565b6102ba565b6040516101119190610bbe565b61014d610148366004610a85565b6104ef565b604051610111929190610bd8565b34801561016757600080fd5b50437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0140610107565b34801561019d57600080fd5b5046610107565b6101b76101b2366004610c60565b610690565b60405161011193929190610cba565b3480156101d257600080fd5b5048610107565b3480156101e557600080fd5b5043610107565b3480156101f857600080fd5b50610107610207366004610ce2565b73ffffffffffffffffffffffffffffffffffffffff163190565b34801561022d57600080fd5b5044610107565b61012d610242366004610a85565b6106ab565b34801561025357600080fd5b5045610107565b34801561026657600080fd5b50604051418152602001610111565b61012d610283366004610c60565b61085a565b6101b7610296366004610a85565b610a1a565b3480156102a757600080fd5b506101076102b6366004610d18565b4090565b60606000828067ffffffffffffffff8111156102d8576102d8610d31565b60405190808252806020026020018201604052801561031e57816020015b6040805180820190915260008152606060208201528152602001906001900390816102f65790505b5092503660005b8281101561047757600085828151811061034157610341610d60565b6020026020010151905087878381811061035d5761035d610d60565b905060200281019061036f9190610d8f565b6040810135958601959093506103886020850185610ce2565b73ffffffffffffffffffffffffffffffffffffffff16816103ac6060870187610dcd565b6040516103ba929190610e32565b60006040518083038185875af1925050503d80600081146103f7576040519150601f19603f3d011682016040523d82523d6000602084013e6103fc565b606091505b50602080850191909152901515808452908501351761046d577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260846000fd5b5050600101610325565b508234146104e6576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f4d756c746963616c6c333a2076616c7565206d69736d6174636800000000000060448201526064015b60405180910390fd5b50505092915050565b436060828067ffffffffffffffff81111561050c5761050c610d31565b60405190808252806020026020018201604052801561053f57816020015b606081526020019060019003908161052a5790505b5091503660005b8281101561068657600087878381811061056257610562610d60565b90506020028101906105749190610e42565b92506105836020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166105a66020850185610dcd565b6040516105b4929190610e32565b6000604051808303816000865af19150503d80600081146105f1576040519150601f19603f3d011682016040523d82523d6000602084013e6105f6565b606091505b5086848151811061060957610609610d60565b602090810291909101015290508061067d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b50600101610546565b5050509250929050565b43804060606106a086868661085a565b905093509350939050565b6060818067ffffffffffffffff8111156106c7576106c7610d31565b60405190808252806020026020018201604052801561070d57816020015b6040805180820190915260008152606060208201528152602001906001900390816106e55790505b5091503660005b828110156104e657600084828151811061073057610730610d60565b6020026020010151905086868381811061074c5761074c610d60565b905060200281019061075e9190610e76565b925061076d6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff166107906040850185610dcd565b60405161079e929190610e32565b6000604051808303816000865af19150503d80600081146107db576040519150601f19603f3d011682016040523d82523d6000602084013e6107e0565b606091505b506020808401919091529015158083529084013517610851577f08c379a000000000000000000000000000000000000000000000000000000000600052602060045260176024527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060445260646000fd5b50600101610714565b6060818067ffffffffffffffff81111561087657610876610d31565b6040519080825280602002602001820160405280156108bc57816020015b6040805180820190915260008152606060208201528152602001906001900390816108945790505b5091503660005b82811015610a105760008482815181106108df576108df610d60565b602002602001015190508686838181106108fb576108fb610d60565b905060200281019061090d9190610e42565b925061091c6020840184610ce2565b73ffffffffffffffffffffffffffffffffffffffff1661093f6020850185610dcd565b60405161094d929190610e32565b6000604051808303816000865af19150503d806000811461098a576040519150601f19603f3d011682016040523d82523d6000602084013e61098f565b606091505b506020830152151581528715610a07578051610a07576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f4d756c746963616c6c333a2063616c6c206661696c656400000000000000000060448201526064016104dd565b506001016108c3565b5050509392505050565b6000806060610a2b60018686610690565b919790965090945092505050565b60008083601f840112610a4b57600080fd5b50813567ffffffffffffffff811115610a6357600080fd5b6020830191508360208260051b8501011115610a7e57600080fd5b9250929050565b60008060208385031215610a9857600080fd5b823567ffffffffffffffff811115610aaf57600080fd5b610abb85828601610a39565b90969095509350505050565b6000815180845260005b81811015610aed57602081850181015186830182015201610ad1565b81811115610aff576000602083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0169290920160200192915050565b600082825180855260208086019550808260051b84010181860160005b84811015610bb1578583037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe001895281518051151584528401516040858501819052610b9d81860183610ac7565b9a86019a9450505090830190600101610b4f565b5090979650505050505050565b602081526000610bd16020830184610b32565b9392505050565b600060408201848352602060408185015281855180845260608601915060608160051b870101935082870160005b82811015610c52577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0888703018452610c40868351610ac7565b95509284019290840190600101610c06565b509398975050505050505050565b600080600060408486031215610c7557600080fd5b83358015158114610c8557600080fd5b9250602084013567ffffffffffffffff811115610ca157600080fd5b610cad86828701610a39565b9497909650939450505050565b838152826020820152606060408201526000610cd96060830184610b32565b95945050505050565b600060208284031215610cf457600080fd5b813573ffffffffffffffffffffffffffffffffffffffff81168114610bd157600080fd5b600060208284031215610d2a57600080fd5b5035919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81833603018112610dc357600080fd5b9190910192915050565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1843603018112610e0257600080fd5b83018035915067ffffffffffffffff821115610e1d57600080fd5b602001915036819003821315610a7e57600080fd5b8183823760009101908152919050565b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc1833603018112610dc357600080fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa1833603018112610dc357600080fd"; + vm.etch(MULTICALL3_ADDRESS, multicall3Code); + try vm.activeFork() { vm.rpc( "anvil_setCode", - string.concat('["', address(CREATE3_FACTORY).toHexString(), '","', code.toHexString(), '"]') // solhint-disable-line quotes + string.concat('["', address(CREATE3_FACTORY).toHexString(), '","', create3Code.toHexString(), '"]') // solhint-disable-line quotes + ); + vm.rpc( + "anvil_setCode", + string.concat('["', MULTICALL3_ADDRESS.toHexString(), '","', multicall3Code.toHexString(), '"]') // solhint-disable-line quotes ); } catch { } // solhint-disable-line no-empty-blocks } From e2d62d471da7f185285aeff9a03a1543d639e251 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Mon, 16 Mar 2026 23:40:50 -0300 Subject: [PATCH 79/84] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20common:=20assert?= =?UTF-8?q?=20multicall=20address=20in=20chain=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/wagmi.config.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/common/wagmi.config.ts b/common/wagmi.config.ts index 26247f041..9d07a1c12 100644 --- a/common/wagmi.config.ts +++ b/common/wagmi.config.ts @@ -169,8 +169,12 @@ function chain(): Plugin { content: `import { anvil, type Chain } from "viem/chains" const chain = anvil as Chain chain.rpcUrls.alchemy = chain.rpcUrls.default +chain.contracts = { multicall3: { address: "${optimism.contracts.multicall3.address}" } } chain.blockExplorers = { default: { name: "Otterscan", url: "http://localhost:5100" } } -export default chain as Chain & { rpcUrls: { alchemy: { http: readonly [string] } } }`, +export default chain as Chain & { + contracts: { multicall3: { address: \`0x\${string}\` } } + rpcUrls: { alchemy: { http: readonly [string] } } +}`, }), }; } @@ -187,7 +191,10 @@ export default chain as Chain & { rpcUrls: { alchemy: { http: readonly [string] run: () => ({ content: `import { ${importName} } from "@account-kit/infra" import { type Chain } from "viem/chains" -export default ${importName} as Chain & { rpcUrls: { alchemy: { http: readonly [string] } } }`, +export default ${importName} as Chain & { + contracts: { multicall3: { address: \`0x\${string}\` } } + rpcUrls: { alchemy: { http: readonly [string] } } +}`, }), }; } From ee13e265e7011fbf23734c07a1470b58f37229a6 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Tue, 17 Feb 2026 19:34:28 -0300 Subject: [PATCH 80/84] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20app:=20replace=20pro?= =?UTF-8?q?posal=20simulation=20with=20simulate=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/tidy-geese-batch.md | 5 + src/utils/useSimulateProposal.ts | 232 +++++++++++---------------- src/utils/wagmi/useSimulateBlocks.ts | 34 ++++ 3 files changed, 130 insertions(+), 141 deletions(-) create mode 100644 .changeset/tidy-geese-batch.md create mode 100644 src/utils/wagmi/useSimulateBlocks.ts diff --git a/.changeset/tidy-geese-batch.md b/.changeset/tidy-geese-batch.md new file mode 100644 index 000000000..c4bcd395d --- /dev/null +++ b/.changeset/tidy-geese-batch.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ replace proposal simulation with simulate blocks diff --git a/src/utils/useSimulateProposal.ts b/src/utils/useSimulateProposal.ts index 15005f42f..7bfcd77af 100644 --- a/src/utils/useSimulateProposal.ts +++ b/src/utils/useSimulateProposal.ts @@ -1,38 +1,22 @@ import { useMemo } from "react"; -import { - bytesToHex, - encodeAbiParameters, - hexToBigInt, - hexToBytes, - keccak256, - toBytes, - zeroAddress, - type Address, - type BlockOverrides, - type Hex, - type StateOverride, -} from "viem"; -import { useBytecode, useSimulateContract } from "wagmi"; +import { encodeAbiParameters, encodeFunctionData, zeroAddress, type Address, type Hex } from "viem"; +import { useBytecode } from "wagmi"; -import chain, { - exaPluginAddress, - exaPreviewerAddress, - proposalManagerAddress, - swapperAddress, -} from "@exactly/common/generated/chain"; +import chain, { proposalManagerAddress } from "@exactly/common/generated/chain"; import { auditorAbi, exaPluginAbi, marketAbi, proposalManagerAbi, upgradeableModularAccountAbi, - useReadExaPreviewerAssets, useReadProposalManagerDelay, useReadProposalManagerQueueNonces, } from "@exactly/common/generated/hooks"; import ProposalType from "@exactly/common/ProposalType"; +import useSimulateBlocks from "./wagmi/useSimulateBlocks"; + export default function useSimulateProposal({ account, amount, @@ -189,145 +173,111 @@ export default function useSimulateProposal({ ) : proposal.receiver && encodeAbiParameters([{ type: "address" }], [proposal.receiver]); const { data: deployed } = useBytecode({ - address: account, + address: account ?? zeroAddress, chainId: chain.id, query: { enabled: enabled && !!account }, }); - const hasMarket = market !== undefined && market !== zeroAddress; - const propose = useSimulateContract({ - account, - address: account, - chainId: chain.id, - functionName: "propose", - abi: [...upgradeableModularAccountAbi, ...exaPluginAbi, ...proposalManagerAbi], - args: hasMarket ? [market, amount ?? 0n, proposal.proposalType, proposalData ?? "0x"] : undefined, - query: { enabled: enabled && !!deployed && !!account && !!amount && hasMarket }, - }); - + const proposalArguments = useMemo( + () => [market ?? zeroAddress, amount ?? 0n, proposal.proposalType, proposalData ?? "0x"] as const, + [amount, market, proposal.proposalType, proposalData], + ); + const proposeRequest = useMemo( + () => + account === undefined + ? undefined + : ({ + account, + address: account, + abi: proposeAbi, + functionName: "propose", + args: proposalArguments, + } as const), + [account, proposalArguments], + ); + const proposeCalldata = useMemo( + () => encodeFunctionData({ abi: proposeAbi, functionName: "propose", args: proposalArguments }), + [proposalArguments], + ); const { data: proposalDelay } = useReadProposalManagerDelay({ address: proposalManagerAddress, chainId: chain.id, query: { enabled }, }); - const { data: assets } = useReadExaPreviewerAssets({ - address: exaPreviewerAddress, - chainId: chain.id, - query: { enabled }, - }); const { data: nonce } = useReadProposalManagerQueueNonces({ address: proposalManagerAddress, chainId: chain.id, args: account ? [account] : undefined, query: { enabled: enabled && !!account }, }); - - const stateOverride = useMemo(() => { - if ( - account === undefined || - amount === undefined || - !hasMarket || - assets === undefined || - nonce === undefined || - proposalData === undefined - ) { - return; - } - const proposalsSlot = hexToBigInt( - keccak256( - encodeAbiParameters( - [{ type: "uint256" }, { type: "bytes32" }], - [nonce, keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [account, 5n]))], - ), - ), - ); - const proposalDataSlot = hexToBigInt(keccak256(encodeAbiParameters([{ type: "uint256" }], [proposalsSlot + 4n]))); - const proposalDataBytes = hexToBytes(proposalData); - return [ + const executeArguments = useMemo(() => [nonce ?? 0n] as const, [nonce]); + const executeRequest = useMemo( + () => + account === undefined + ? undefined + : ({ + account, + address: account, + abi: executeProposalAbi, + functionName: "executeProposal", + args: executeArguments, + } as const), + [account, executeArguments], + ); + const executeCalldata = useMemo( + () => encodeFunctionData({ abi: executeProposalAbi, functionName: "executeProposal", args: executeArguments }), + [executeArguments], + ); + const simulationTime = useMemo( + () => (proposalDelay === undefined ? undefined : BigInt(Math.floor(Date.now() / 1000)) + proposalDelay), + [account, amount, market, nonce, proposal.proposalType, proposalData, proposalDelay], + ); + const simulation = useSimulateBlocks({ + blocks: [ + { calls: [{ account, to: account ?? zeroAddress, data: proposeCalldata }] }, { - address: proposalManagerAddress, - state: [ - { - // nonces[account] - slot: keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [account, 3n])), - value: encodeAbiParameters([{ type: "uint256" }], [nonce]), - }, - { - // queueNonces[account] - slot: keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [account, 4n])), - value: encodeAbiParameters([{ type: "uint256" }], [nonce + 1n]), - }, - { - // proposals[account][nonce][0] (amount) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalsSlot]), - value: encodeAbiParameters([{ type: "uint256" }], [amount]), - }, - { - // proposals[account][nonce][1] (market) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalsSlot + 1n]), - value: encodeAbiParameters([{ type: "address" }], [market]), - }, - { - // proposals[account][nonce][3] (proposalType) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalsSlot + 3n]), - value: encodeAbiParameters([{ type: "uint8" }], [proposal.proposalType]), - }, - { - // proposals[account][nonce][4] (2 * proposalData.length + 1) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalsSlot + 4n]), - value: encodeAbiParameters([{ type: "uint256" }], [BigInt(2 * proposalDataBytes.length + 1)]), - }, - ...Array.from({ length: Math.ceil(proposalDataBytes.length / 32) }, (_, index) => ({ - // keccak256(proposalData.slot) (proposalData) - slot: encodeAbiParameters([{ type: "uint256" }], [proposalDataSlot + BigInt(index)]), - value: encodeAbiParameters( - [{ type: "bytes32" }], - [bytesToHex(proposalDataBytes.slice(index * 32, (index + 1) * 32))], - ), - })), - { - // hasRole(PROPOSER_ROLE, exaPlugin) - slot: keccak256( - encodeAbiParameters( - [{ type: "address" }, { type: "bytes32" }], - [ - exaPluginAddress, - keccak256( - encodeAbiParameters( - [{ type: "bytes32" }, { type: "uint256" }], - [keccak256(toBytes("PROPOSER_ROLE")), 0n], - ), - ), - ], - ), - ), - value: encodeAbiParameters([{ type: "bool" }], [true]), - }, - ...[swapperAddress, ...assets.map(({ asset }) => asset)].map((target) => ({ - // allowlist[target] - slot: keccak256(encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [target, 2n])), - value: encodeAbiParameters([{ type: "bool" }], [true]), - })), - ], + blockOverrides: simulationTime === undefined ? undefined : { time: simulationTime }, + calls: [{ account, to: account ?? zeroAddress, data: executeCalldata }], }, - ] satisfies StateOverride; - }, [account, amount, assets, hasMarket, market, nonce, proposal.proposalType, proposalData]); - const blockOverrides = - proposalDelay === undefined - ? undefined - : ({ time: BigInt(Math.floor(Date.now() / 1000)) + proposalDelay } satisfies BlockOverrides); - const executeProposal = useSimulateContract({ - account, - address: account, + ], chainId: chain.id, - functionName: "executeProposal", - args: [nonce ?? 0n], - abi: [...upgradeableModularAccountAbi, ...exaPluginAbi, ...proposalManagerAbi, ...auditorAbi, ...marketAbi], - stateOverride, - blockOverrides, query: { - enabled: enabled && !!deployed && nonce !== undefined && !!account && !!stateOverride && !!blockOverrides, + enabled: enabled && !!deployed && !!account && !!amount && nonce !== undefined && simulationTime !== undefined, }, }); - + const proposeCall = simulation.data?.[0]?.calls[0] as CallResult | undefined; + const executeCall = simulation.data?.[1]?.calls[0] as CallResult | undefined; + const propose = { + ...simulation, + data: + proposeCall?.status === "success" && proposeRequest + ? { request: proposeRequest, result: proposeCall.result } + : undefined, + error: simulation.error ?? (proposeCall?.status === "failure" ? proposeCall.error : null), + }; + const executeProposal = { + ...simulation, + data: + executeCall?.status === "success" && executeRequest + ? { request: executeRequest, result: executeCall.result } + : undefined, + error: + simulation.error ?? + (proposeCall?.status === "failure" + ? proposeCall.error + : executeCall?.status === "failure" + ? executeCall.error + : null), + }; return { propose, executeProposal, proposalData }; } + +const proposeAbi = [...upgradeableModularAccountAbi, ...exaPluginAbi, ...proposalManagerAbi]; +const executeProposalAbi = [ + ...upgradeableModularAccountAbi, + ...exaPluginAbi, + ...proposalManagerAbi, + ...auditorAbi, + ...marketAbi, +]; + +type CallResult = { error: Error; status: "failure" } | { result: unknown; status: "success" }; diff --git a/src/utils/wagmi/useSimulateBlocks.ts b/src/utils/wagmi/useSimulateBlocks.ts new file mode 100644 index 000000000..afe4b021f --- /dev/null +++ b/src/utils/wagmi/useSimulateBlocks.ts @@ -0,0 +1,34 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { + simulateBlocks, + type SimulateBlocksErrorType, + type SimulateBlocksParameters, + type SimulateBlocksReturnType, +} from "viem/actions"; +import { useChainId, useConfig, type Config } from "wagmi"; +import { hashFn, structuralSharing } from "wagmi/query"; + +// TODO remove after https://github.com/wevm/wagmi/pull/4993 +export default function useSimulateBlocks({ + config: configParameter, + chainId: chainIdParameter, + query, + ...parameters +}: SimulateBlocksParameters & { chainId?: number; config?: Config; query?: QueryOptions }) { + const config = useConfig({ config: configParameter }); + const chainId = useChainId({ config }); + const resolvedChainId = chainIdParameter ?? chainId; + return useQuery({ + ...query, + queryKey: ["simulateBlocks", { chainId: resolvedChainId, ...parameters }], + queryKeyHashFn: hashFn, + structuralSharing, + enabled: query?.enabled ?? true, + queryFn: () => simulateBlocks(config.getClient({ chainId: resolvedChainId }), parameters), + }); +} + +type QueryOptions = Omit< + UseQueryOptions, SimulateBlocksErrorType, SimulateBlocksReturnType>, + "queryFn" | "queryKey" | "queryKeyHashFn" +>; From 801cc5c706c8eb3138a40b6e866854a116782c93 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Tue, 24 Mar 2026 17:02:22 -0300 Subject: [PATCH 81/84] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20app:=20flatten=20sim?= =?UTF-8?q?ulate-proposal=20api=20and=20batch=20contract=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/bold-ram-dig.md | 5 + cspell.json | 1 + src/components/pay-mode/Pay.tsx | 43 +-- src/components/roll-debt/RollDebt.tsx | 120 +++--- src/components/send-funds/Amount.tsx | 174 +++------ src/components/swaps/Swaps.tsx | 85 +---- src/utils/useSimulateProposal.ts | 515 ++++++++++++++++---------- 7 files changed, 461 insertions(+), 482 deletions(-) create mode 100644 .changeset/bold-ram-dig.md diff --git a/.changeset/bold-ram-dig.md b/.changeset/bold-ram-dig.md new file mode 100644 index 000000000..cca53e6bd --- /dev/null +++ b/.changeset/bold-ram-dig.md @@ -0,0 +1,5 @@ +--- +"@exactly/mobile": patch +--- + +♻️ flatten simulate-proposal api and batch contract reads diff --git a/cspell.json b/cspell.json index 27825d301..74f602936 100644 --- a/cspell.json +++ b/cspell.json @@ -108,6 +108,7 @@ "mload", "modelcontextprotocol", "moti", + "multicall", "mysten", "natspec", "nfmelendez", diff --git a/src/components/pay-mode/Pay.tsx b/src/components/pay-mode/Pay.tsx index fb7983f10..75753e2b1 100644 --- a/src/components/pay-mode/Pay.tsx +++ b/src/components/pay-mode/Pay.tsx @@ -28,7 +28,6 @@ import chain, { } from "@exactly/common/generated/chain"; import { auditorAbi, - exaPluginAbi, integrationPreviewerAbi, marketAbi, upgradeableModularAccountAbi, @@ -287,33 +286,34 @@ export default function Pay() { const maxAmountIn = route?.fromAmount ? pad(route.fromAmount, SLIPPAGE_DIVISOR) + 69n : undefined; // HACK try to avoid ZERO_SHARES on dust deposit const { - propose: { data: repayPropose }, - executeProposal: { error: repayExecuteProposalError, isPending: isSimulatingRepay }, - proposalData: repayProposalData, + request: repayPropose, + error: repayExecuteProposalError, + isPending: isSimulatingRepay, } = useSimulateProposal({ account, amount: maxRepay, market: selectedAsset.address, - enabled: enableSimulations && mode === "repay" && positionAssets > 0n, proposalType: ProposalType.RepayAtMaturity, maturity, positionAssets, + enabled: enableSimulations && mode === "repay" && positionAssets > 0n, }); const { - propose: { data: crossRepayPropose }, - executeProposal: { error: crossRepayExecuteProposalError, isPending: isSimulatingCrossRepay }, - proposalData: crossRepayProposalData, + request: crossRepayPropose, + error: crossRepayExecuteProposalError, + isPending: isSimulatingCrossRepay, } = useSimulateProposal({ account, amount: maxAmountIn, market: selectedAsset.address, - enabled: enableSimulations && mode === "crossRepay" && positionAssets > 0n, proposalType: ProposalType.CrossRepayAtMaturity, + marketOut: marketUSDCAddress, maturity, positionAssets, maxRepay, route: route?.data, + enabled: enableSimulations && mode === "crossRepay" && positionAssets > 0n && !!route, }); const { @@ -382,14 +382,10 @@ export default function Pay() { const call = (() => { switch (mode) { case "repay": - if (!repayPropose || !selectedAsset.address) throw new Error("no repay simulation"); + if (!repayPropose) throw new Error("no repay simulation"); return { - to: repayPropose.request.address, - data: encodeFunctionData({ - abi: exaPluginAbi, - functionName: "propose", - args: [selectedAsset.address, maxRepay ?? 0n, ProposalType.RepayAtMaturity, repayProposalData ?? "0x"], - }), + to: repayPropose.address, + data: encodeFunctionData(repayPropose), }; case "legacyRepay": { @@ -398,19 +394,10 @@ export default function Pay() { return { to: address, data: encodeFunctionData({ abi, functionName, args }) }; } case "crossRepay": - if (!crossRepayPropose || !selectedAsset.address) throw new Error("no cross repay simulation"); + if (!crossRepayPropose) throw new Error("no cross repay simulation"); return { - to: crossRepayPropose.request.address, - data: encodeFunctionData({ - abi: exaPluginAbi, - functionName: "propose", - args: [ - selectedAsset.address, - maxAmountIn ?? 0n, - ProposalType.CrossRepayAtMaturity, - crossRepayProposalData ?? "0x", - ], - }), + to: crossRepayPropose.address, + data: encodeFunctionData(crossRepayPropose), }; case "legacyCrossRepay": { diff --git a/src/components/roll-debt/RollDebt.tsx b/src/components/roll-debt/RollDebt.tsx index 4b556ef40..d83af7722 100644 --- a/src/components/roll-debt/RollDebt.tsx +++ b/src/components/roll-debt/RollDebt.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Pressable } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -9,19 +9,14 @@ import { ArrowLeft, ArrowRight } from "@tamagui/lucide-icons"; import { useToastController } from "@tamagui/toast"; import { ScrollView, Separator, Spinner, XStack, YStack } from "tamagui"; -import { useMutation } from "@tanstack/react-query"; -import { waitForCallsStatus } from "@wagmi/core/actions"; import { nonEmpty, pipe, safeParse, string } from "valibot"; -import { ContractFunctionExecutionError, encodeAbiParameters, encodeFunctionData } from "viem"; -import { useBytecode, useSendCalls } from "wagmi"; +import { ContractFunctionExecutionError } from "viem"; +import { useWriteContract } from "wagmi"; -import alchemyAPIKey from "@exactly/common/alchemyAPIKey"; -import alchemyGasPolicyId from "@exactly/common/alchemyGasPolicyId"; import chain, { exaPreviewerAddress, marketUSDCAddress, previewerAddress } from "@exactly/common/generated/chain"; import { useReadExaPreviewerPendingProposals, useReadPreviewerPreviewBorrowAtMaturity, - useSimulateExaPluginPropose, } from "@exactly/common/generated/hooks"; import ProposalType from "@exactly/common/ProposalType"; import { MATURITY_INTERVAL, WAD } from "@exactly/lib"; @@ -32,7 +27,7 @@ import View from "../../components/shared/View"; import reportError from "../../utils/reportError"; import useAccount from "../../utils/useAccount"; import useAsset from "../../utils/useAsset"; -import exa from "../../utils/wagmi/exa"; +import useSimulateProposal from "../../utils/useSimulateProposal"; import Button from "../shared/Button"; import Skeleton from "../shared/Skeleton"; @@ -56,13 +51,11 @@ export default function Pay() { const borrow = exaUSDC?.fixedBorrowPositions.find((b) => b.maturity === BigInt(success ? repayMaturity : 0)); const rolloverMaturityBorrow = exaUSDC?.fixedBorrowPositions.find((b) => b.maturity === BigInt(borrowMaturity)); - const { data: bytecode } = useBytecode({ address, chainId: chain.id, query: { enabled: !!address } }); - const { data: borrowPreview } = useReadPreviewerPreviewBorrowAtMaturity({ address: previewerAddress, chainId: chain.id, args: [marketUSDCAddress, BigInt(borrowMaturity), borrow?.previewValue ?? 0n], - query: { enabled: !!bytecode && !!exaUSDC && !!borrow && !!address && !!borrowMaturity }, + query: { enabled: !!exaUSDC && !!borrow && !!address && !!borrowMaturity }, }); if (!success || !exaUSDC || !borrow) return null; @@ -234,35 +227,22 @@ function RolloverButton({ const { t } = useTranslation(); const { address } = useAccount(); const router = useRouter(); - const { data: bytecode } = useBytecode({ address, query: { enabled: !!address } }); const toast = useToastController(); const slippage = (WAD * 105n) / 100n; const maxRepayAssets = (borrow.previewValue * slippage) / WAD; const percentage = WAD; - const { data: proposeSimulation } = useSimulateExaPluginPropose({ - address, - args: [ - marketUSDCAddress, - maxRepayAssets, - ProposalType.RollDebt, - encodeAbiParameters( - [ - { - type: "tuple", - components: [ - { name: "repayMaturity", type: "uint256" }, - { name: "borrowMaturity", type: "uint256" }, - { name: "maxRepayAssets", type: "uint256" }, - { name: "percentage", type: "uint256" }, - ], - }, - ], - [{ repayMaturity, borrowMaturity, maxRepayAssets, percentage }], - ), - ], - query: { enabled: !!address && !!bytecode }, + const { request: proposeSimulation, error: executeProposalError } = useSimulateProposal({ + account: address, + amount: maxRepayAssets, + market: marketUSDCAddress, + proposalType: ProposalType.RollDebt, + borrowMaturity, + maxRepayAssets, + percentage, + repayMaturity, + enabled: !!address, }); const { @@ -272,50 +252,41 @@ function RolloverButton({ } = useReadExaPreviewerPendingProposals({ address: exaPreviewerAddress, args: address ? [address] : undefined, - query: { enabled: !!address && !!bytecode, gcTime: 0, refetchInterval: 30_000 }, + query: { enabled: !!address, gcTime: 0, refetchInterval: 30_000 }, }); - const { mutateAsync: mutateSendCalls } = useSendCalls(); const { - mutate: proposeRollDebt, + mutate, isPending: isProposeRollDebtPending, error: proposeRollDebtError, - } = useMutation({ - async mutationFn() { - if (!address) throw new Error("no address"); - if (!proposeSimulation) throw new Error("no propose roll debt simulation"); - const { address: to, abi, functionName, args } = proposeSimulation.request; - const { id } = await mutateSendCalls({ - calls: [{ to, data: encodeFunctionData({ abi, functionName, args }) }], - capabilities: { - paymasterService: { - url: `${chain.rpcUrls.alchemy.http[0]}/${alchemyAPIKey}`, - context: { policyId: alchemyGasPolicyId }, - }, - }, - }); - const { status } = await waitForCallsStatus(exa, { id }); - if (status === "failure") throw new Error("failed to propose rollover"); - }, - onSuccess() { - toast.show(t("Processing rollover"), { - native: true, - duration: 1000, - burntOptions: { haptic: "success", preset: "done" }, - }); - if (address && bytecode) refetchPendingProposals().catch(reportError); - router.dismissTo("/activity"); - }, - onError(error) { - toast.show(t("Rollover failed"), { - native: true, - duration: 1000, - burntOptions: { haptic: "error", preset: "error" }, - }); - reportError(error); + } = useWriteContract({ + mutation: { + onSuccess: () => { + toast.show(t("Processing rollover"), { + native: true, + duration: 1000, + burntOptions: { haptic: "success", preset: "done" }, + }); + if (address) refetchPendingProposals().catch(reportError); + router.dismissTo("/activity"); + }, + onError: (error) => { + toast.show(t("Rollover failed"), { + native: true, + duration: 1000, + burntOptions: { haptic: "error", preset: "error" }, + }); + reportError(error); + }, }, }); + const proposeRollDebt = useCallback(() => { + if (!address) throw new Error("no address"); + if (!proposeSimulation) throw new Error("no propose roll debt simulation"); + mutate(proposeSimulation); + }, [address, proposeSimulation, mutate]); + const hasProposed = pendingProposals?.some( ({ proposal }) => proposal.market === marketUSDCAddress && @@ -331,7 +302,12 @@ function RolloverButton({ ); const disabled = - !!isError || isProposeRollDebtPending || isPendingProposalsPending || !proposeSimulation || hasProposed; + !!isError || + !!executeProposalError || + isProposeRollDebtPending || + isPendingProposalsPending || + !proposeSimulation || + hasProposed; return (