Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swift-melons-collab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/server": patch
---

馃敡 add base art
1 change: 1 addition & 0 deletions common/panda.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const BASE_PRODUCT_ID = "408";
export const PLATINUM_PRODUCT_ID = "406";
export const SIGNATURE_PRODUCT_ID = "424";
27 changes: 16 additions & 11 deletions server/api/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand All @@ -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] }),
),
});

Expand Down Expand Up @@ -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
Comment thread
nfmelendez marked this conversation as resolved.
Comment thread
nfmelendez marked this conversation as resolved.
: SIGNATURE_PRODUCT_ID;
Comment thread
nfmelendez marked this conversation as resolved.
Comment thread
nfmelendez marked this conversation as resolved.
const card = await getCards(pandaId)
.then((pandaCards) => pandaCards.find(({ status }) => status === "active"))
.then(async (orphan) => {
Expand All @@ -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
Expand All @@ -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 }]);
Comment thread
nfmelendez marked this conversation as resolved.
track({
event: "CardIssued",
userId: account,
properties: { productId: SIGNATURE_PRODUCT_ID, source: credential.source },
properties: { productId, source: credential.source },
});

if (isUpgradeFromPlatinum) handlePlatinumUpgrade(credentialId, account);
Expand Down Expand Up @@ -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<typeof CreatedCardResponse>,
200,
);
Expand Down
124 changes: 123 additions & 1 deletion server/test/api/card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
10 changes: 7 additions & 3 deletions server/utils/panda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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(
Expand All @@ -85,6 +85,7 @@ export async function createCard(
: {
[PLATINUM_PRODUCT_ID]: "81e42f27affd4e328f19651d4f2b438e",
[SIGNATURE_PRODUCT_ID]: "398c4919514b4ec4927e6a9114a4c816",
[BASE_PRODUCT_ID]: "79c1c868c3ae4b4dae2564295e75c357",
}[productId],
},
}),
Expand Down Expand Up @@ -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({
Expand Down