diff --git a/.changeset/swift-melons-collab.md b/.changeset/swift-melons-collab.md new file mode 100644 index 0000000000..5b3016e71c --- /dev/null +++ b/.changeset/swift-melons-collab.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🔧 add base art diff --git a/common/panda.ts b/common/panda.ts index 1eba51375f..6d82b0b82d 100644 --- a/common/panda.ts +++ b/common/panda.ts @@ -1,2 +1,3 @@ +export const BASE_PRODUCT_ID = "408"; export const PLATINUM_PRODUCT_ID = "406"; export const SIGNATURE_PRODUCT_ID = "424"; diff --git a/server/api/card.ts b/server/api/card.ts index 5b440e3369..de435aa74a 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -29,12 +29,13 @@ import { type InferInput, type InferOutput, } from "valibot"; +import { base } from "viem/chains"; import { createSiweMessage, parseSiweMessage, verifySiweMessage } from "viem/siwe"; import domain from "@exactly/common/domain"; import chain from "@exactly/common/generated/chain"; import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS"; -import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; +import { BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; import { Address, Base64URL, Hex } from "@exactly/common/validation"; import database, { cards, credentials } from "../database"; @@ -95,8 +96,8 @@ const CardResponse = object({ ]), }), productId: pipe( - picklist([PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), - metadata({ examples: [PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID] }), + picklist([BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), + metadata({ examples: [BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID] }), ), challenge: optional(pipe(string(), metadata({ examples: ["1a2b3c"] }))), provisioning: optional( @@ -112,8 +113,8 @@ const CreatedCardResponse = object({ cardId: pipe(string(), uuid(), metadata({ examples: ["123e4567-e89b-12d3-a456-426655440000"] })), status: pipe(picklist(["ACTIVE", "FROZEN"]), metadata({ examples: ["ACTIVE", "FROZEN"] })), productId: pipe( - picklist([PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), - metadata({ examples: [PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID] }), + picklist([BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), + metadata({ examples: [BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID] }), ), }); @@ -494,6 +495,12 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str if (kyc.applicationStatus !== "approved") { return c.json({ code: "kyc not approved" }, 403); } + const productId = + chain.id === base.id + ? credential.source === "5lu2sNu0v0ZElC2m77QR3rAZBHLr8PoG" // cspell:ignore azbh + ? SIGNATURE_PRODUCT_ID + : BASE_PRODUCT_ID + : SIGNATURE_PRODUCT_ID; const card = await getCards(pandaId) .then((pandaCards) => pandaCards.find(({ status }) => status === "active")) .then(async (orphan) => { @@ -511,7 +518,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str } else { return createCard( pandaId, - SIGNATURE_PRODUCT_ID, + productId, await getAccount(credentialId, "cardLimit") .then((persona) => persona?.attributes.fields.card_limit_usd?.value == null @@ -534,13 +541,11 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str } catch (error) { captureException(error); } - await database - .insert(cards) - .values([{ id: card.id, credentialId, lastFour: card.last4, mode, productId: SIGNATURE_PRODUCT_ID }]); + await database.insert(cards).values([{ id: card.id, credentialId, lastFour: card.last4, mode, productId }]); track({ event: "CardIssued", userId: account, - properties: { productId: SIGNATURE_PRODUCT_ID, source: credential.source }, + properties: { productId, source: credential.source }, }); if (isUpgradeFromPlatinum) handlePlatinumUpgrade(credentialId, account); @@ -574,7 +579,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str lastFour: card.last4, status: "ACTIVE", cardId: card.id, - productId: SIGNATURE_PRODUCT_ID, + productId, } satisfies InferOutput, 200, ); diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index df3930140f..4ec485517c 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -13,13 +13,14 @@ import { testClient } from "hono/testing"; import { parse } from "valibot"; import { checksumAddress, hexToBigInt, padHex, parseEther, zeroHash } from "viem"; import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts"; +import { base, optimism } from "viem/chains"; import { createSiweMessage, parseSiweMessage } from "viem/siwe"; import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; import domain from "@exactly/common/domain"; import chain, { exaAccountFactoryAbi, exaPluginAbi } from "@exactly/common/generated/chain"; -import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; +import { BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; import { Address } from "@exactly/common/validation"; import app from "../../api/card"; @@ -945,6 +946,127 @@ describe("authenticated", () => { }); }); + describe("product selection by chain", () => { + const defaultChainId = chain.id; + + beforeAll(async () => { + await database.insert(credentials).values([ + { + id: "base-default", + publicKey: new Uint8Array(), + account: padHex("0xba51", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "base-default-panda", + source: "some-other-source", + }, + { + id: "base-signature", + publicKey: new Uint8Array(), + account: padHex("0xba52", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "base-signature-panda", + source: "5lu2sNu0v0ZElC2m77QR3rAZBHLr8PoG", // cspell:ignore azbh + }, + { + id: "optimism-credential", + publicKey: new Uint8Array(), + account: padHex("0x0b71", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "optimism-panda", + source: null, + }, + ]); + }); + + afterEach(() => { + chain.id = defaultChainId; + }); + + it("issues a base product card on base", async () => { + chain.id = base.id; + vi.spyOn(panda, "autoCredit").mockResolvedValue(false); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ + id: "base-default-panda", + applicationStatus: "approved", + }); + const createCard = vi + .spyOn(panda, "createCard") + .mockResolvedValueOnce({ ...cardTemplate, id: "543c1771-beae-4f26-b662-44ea48b40ba1", last4: "4081" }); + + const response = await appClient.index.$post({ header: { "test-credential-id": "base-default" } }); + + expect(response.status).toBe(200); + expect(createCard).toHaveBeenCalledWith("base-default-panda", BASE_PRODUCT_ID, undefined); + await expect(response.json()).resolves.toStrictEqual({ + status: "ACTIVE", + lastFour: "4081", + cardId: "543c1771-beae-4f26-b662-44ea48b40ba1", + productId: BASE_PRODUCT_ID, + }); + const created = await database.query.cards.findFirst({ + columns: { productId: true }, + where: eq(cards.credentialId, "base-default"), + }); + expect(created?.productId).toBe(BASE_PRODUCT_ID); + }); + + it("issues a signature product card on base for the override source", async () => { + chain.id = base.id; + vi.spyOn(panda, "autoCredit").mockResolvedValue(false); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ + id: "base-signature-panda", + applicationStatus: "approved", + }); + const createCard = vi + .spyOn(panda, "createCard") + .mockResolvedValueOnce({ ...cardTemplate, id: "543c1771-beae-4f26-b662-44ea48b40ba2", last4: "4242" }); + + const response = await appClient.index.$post({ header: { "test-credential-id": "base-signature" } }); + + expect(response.status).toBe(200); + expect(createCard).toHaveBeenCalledWith("base-signature-panda", SIGNATURE_PRODUCT_ID, undefined); + await expect(response.json()).resolves.toStrictEqual({ + status: "ACTIVE", + lastFour: "4242", + cardId: "543c1771-beae-4f26-b662-44ea48b40ba2", + productId: SIGNATURE_PRODUCT_ID, + }); + const created = await database.query.cards.findFirst({ + columns: { productId: true }, + where: eq(cards.credentialId, "base-signature"), + }); + expect(created?.productId).toBe(SIGNATURE_PRODUCT_ID); + }); + + it("issues a signature product card on optimism", async () => { + chain.id = optimism.id; + vi.spyOn(panda, "autoCredit").mockResolvedValue(false); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ + id: "optimism-panda", + applicationStatus: "approved", + }); + const createCard = vi + .spyOn(panda, "createCard") + .mockResolvedValueOnce({ ...cardTemplate, id: "543c1771-beae-4f26-b662-44ea48b40fa1", last4: "1010" }); + + const response = await appClient.index.$post({ header: { "test-credential-id": "optimism-credential" } }); + + expect(response.status).toBe(200); + expect(createCard).toHaveBeenCalledWith("optimism-panda", SIGNATURE_PRODUCT_ID, undefined); + await expect(response.json()).resolves.toStrictEqual({ + status: "ACTIVE", + lastFour: "1010", + cardId: "543c1771-beae-4f26-b662-44ea48b40fa1", + productId: SIGNATURE_PRODUCT_ID, + }); + const created = await database.query.cards.findFirst({ + columns: { productId: true }, + where: eq(cards.credentialId, "optimism-credential"), + }); + expect(created?.productId).toBe(SIGNATURE_PRODUCT_ID); + }); + }); + it("adds user to pax when signature card is issued (upgrade from platinum)", async () => { const testCredentialId = "pax-test"; const testAccount = padHex("0x999", { size: 20 }); diff --git a/server/utils/panda.ts b/server/utils/panda.ts index c8ca5ce7bc..fda59c9b94 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -43,7 +43,7 @@ import chain, { previewerAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; -import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; +import { BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; import { Address, Hash } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; @@ -66,7 +66,7 @@ export default key; export async function createCard( userId: string, - productId: typeof PLATINUM_PRODUCT_ID | typeof SIGNATURE_PRODUCT_ID, + productId: typeof BASE_PRODUCT_ID | typeof PLATINUM_PRODUCT_ID | typeof SIGNATURE_PRODUCT_ID, amount = 1_000_000, ) { return await request( @@ -85,6 +85,7 @@ export async function createCard( : { [PLATINUM_PRODUCT_ID]: "81e42f27affd4e328f19651d4f2b438e", [SIGNATURE_PRODUCT_ID]: "398c4919514b4ec4927e6a9114a4c816", + [BASE_PRODUCT_ID]: "79c1c868c3ae4b4dae2564295e75c357", }[productId], }, }), @@ -292,7 +293,10 @@ const CreateCardRequest = object({ "allTime", ]), }), - configuration: object({ productId: picklist([PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), virtualCardArt: string() }), + configuration: object({ + productId: picklist([BASE_PRODUCT_ID, PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID]), + virtualCardArt: string(), + }), }); const CardResponse = object({