From e0aba854237d096cd7dfa0b5503977235b32ddc1 Mon Sep 17 00:00:00 2001 From: thephez Date: Wed, 3 Jun 2026 16:26:23 -0400 Subject: [PATCH 1/5] fix(dashmint-lab): reserve price chip height so card rows align Co-Authored-By: Claude Opus 4.7 (1M context) --- example-apps/dashmint-lab/src/components/CardTile.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example-apps/dashmint-lab/src/components/CardTile.tsx b/example-apps/dashmint-lab/src/components/CardTile.tsx index 5a42e35..5b5d06a 100644 --- a/example-apps/dashmint-lab/src/components/CardTile.tsx +++ b/example-apps/dashmint-lab/src/components/CardTile.tsx @@ -91,8 +91,8 @@ export function CardTile({ style={{ background: RARITY_RAIL_COLORS[rarity] }} /> - {/* Header: rarity tag + price */} -
+ {/* Header: reserves chip height so listed and unlisted cards align */} +
{hasPrice && ( Date: Tue, 9 Jun 2026 11:53:13 -0400 Subject: [PATCH 2/5] fix(dashmint-lab): cap card price below SDK serialization limit The SDK fails to safely serialize document prices above Number.MAX_SAFE_INTEGER (dashpay/platform#3786). Cap the SetPrice modal at 1,000,000,000,000,000 credits and reject larger values with an inline error, until the SDK fix lands. Also tighten the parse to require digits-only (Number() accepts hex, exponents, whitespace) and set noValidate on the form so the app-level error path runs instead of the browser's native validation popover. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/SetPriceModal.tsx | 20 +++++++++++++--- .../dashmint-lab/test/SetPriceModal.test.tsx | 24 ++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/example-apps/dashmint-lab/src/components/SetPriceModal.tsx b/example-apps/dashmint-lab/src/components/SetPriceModal.tsx index e34f876..bd3ecb7 100644 --- a/example-apps/dashmint-lab/src/components/SetPriceModal.tsx +++ b/example-apps/dashmint-lab/src/components/SetPriceModal.tsx @@ -18,6 +18,10 @@ export interface SetPriceModalProps { } const SUCCESS_CLOSE_DELAY_MS = 700; +// TODO(dashpay/platform#3786): Remove this app-level cap after the SDK can +// safely serialize document prices above Number.MAX_SAFE_INTEGER. +export const MAX_PRICE_CREDITS = 1_000_000_000_000_000; +const MAX_PRICE_ERROR = `Price must be between 1 and ${formatCredits(MAX_PRICE_CREDITS)} credits.`; export function SetPriceModal({ card, onClose, onPriced }: SetPriceModalProps) { const session = useSession(); @@ -66,8 +70,13 @@ export function SetPriceModal({ card, onClose, onPriced }: SetPriceModalProps) { async function handleSubmit(e: FormEvent) { e.preventDefault(); - const n = parseInt(amount, 10); - if (!Number.isFinite(n) || n < 1) return; + const value = amount.trim(); + const n = Number(value); + if (!/^\d+$/.test(value) || !Number.isFinite(n) || n < 1) return; + if (n > MAX_PRICE_CREDITS) { + setResult({ kind: "error", message: MAX_PRICE_ERROR }); + return; + } await submitPrice(n); } @@ -80,7 +89,11 @@ export function SetPriceModal({ card, onClose, onPriced }: SetPriceModalProps) { onClose={onClose} > {card && ( -
+ {hasCurrentPrice && (
@@ -95,6 +108,7 @@ export function SetPriceModal({ card, onClose, onPriced }: SetPriceModalProps) { { setAmount(e.target.value); diff --git a/example-apps/dashmint-lab/test/SetPriceModal.test.tsx b/example-apps/dashmint-lab/test/SetPriceModal.test.tsx index 9dec7e1..e966d74 100644 --- a/example-apps/dashmint-lab/test/SetPriceModal.test.tsx +++ b/example-apps/dashmint-lab/test/SetPriceModal.test.tsx @@ -9,7 +9,10 @@ import { } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { SetPriceModal } from "../src/components/SetPriceModal"; +import { + MAX_PRICE_CREDITS, + SetPriceModal, +} from "../src/components/SetPriceModal"; import type { Card } from "../src/dash/queries"; import type { DashKeyManager, DashSdk } from "../src/dash/types"; @@ -128,6 +131,25 @@ describe("SetPriceModal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("sets a maximum price and blocks larger values", async () => { + mockUseSession.mockReturnValue(sessionValue); + + render(); + + const priceInput = screen.getByLabelText("Price"); + expect(priceInput.getAttribute("max")).toBe(String(MAX_PRICE_CREDITS)); + + fireEvent.change(priceInput, { + target: { value: String(MAX_PRICE_CREDITS + 1) }, + }); + fireEvent.click(screen.getByRole("button", { name: "List for sale" })); + + expect(mockSetPrice).not.toHaveBeenCalled(); + expect(screen.getByRole("alert").textContent).toContain( + "Price must be between 1 and 1,000,000,000,000,000 credits.", + ); + }); + it("treats $price === 0n as unlisted (zero is not a valid price)", () => { mockUseSession.mockReturnValue(sessionValue); From f3f198b47053b05bf0ed2c7204e00d50f3c318c6 Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 9 Jun 2026 13:38:18 -0400 Subject: [PATCH 3/5] test(dashmint-lab): add unit coverage for dash ops, queries, hooks, and UI primitives Cover the src/dash card operation wrappers (transfer, setPrice, purchase, burn), the card query helpers, the errorMessage logger helper, the useDpnsName and useResolvedRecipient hooks, and the AppShell/SubTabs/CardGrid/HowItWorks UI primitives. These units were previously exercised only indirectly through mocked modal and app tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/DashCardOperations.test.ts | 189 +++++++++++++++ .../dashmint-lab/test/UiPrimitives.test.tsx | 207 ++++++++++++++++ example-apps/dashmint-lab/test/logger.test.ts | 36 +++ .../dashmint-lab/test/queries.test.ts | 148 ++++++++++++ .../dashmint-lab/test/useDpnsName.test.tsx | 125 ++++++++++ .../test/useResolvedRecipient.test.tsx | 224 ++++++++++++++++++ 6 files changed, 929 insertions(+) create mode 100644 example-apps/dashmint-lab/test/DashCardOperations.test.ts create mode 100644 example-apps/dashmint-lab/test/UiPrimitives.test.tsx create mode 100644 example-apps/dashmint-lab/test/logger.test.ts create mode 100644 example-apps/dashmint-lab/test/queries.test.ts create mode 100644 example-apps/dashmint-lab/test/useDpnsName.test.tsx create mode 100644 example-apps/dashmint-lab/test/useResolvedRecipient.test.tsx diff --git a/example-apps/dashmint-lab/test/DashCardOperations.test.ts b/example-apps/dashmint-lab/test/DashCardOperations.test.ts new file mode 100644 index 0000000..2420aaf --- /dev/null +++ b/example-apps/dashmint-lab/test/DashCardOperations.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { burnCard } from "../src/dash/burnCard"; +import { purchaseCard } from "../src/dash/purchaseCard"; +import { setPrice } from "../src/dash/setPrice"; +import { transferCard } from "../src/dash/transferCard"; +import type { DashKeyManager, DashSdk } from "../src/dash/types"; + +const { mockWithAuthedCard } = vi.hoisted(() => ({ + mockWithAuthedCard: vi.fn(), +})); + +vi.mock("../src/dash/withAuthedCard", () => ({ + withAuthedCard: mockWithAuthedCard, +})); + +const identity = { id: "buyer-identity" }; +const identityKey = { id: "auth-key" }; +const signer = { id: "signer" }; +const doc = { id: "card-1", revision: 2n }; + +const baseParams = { + sdk: { + documents: { + transfer: vi.fn(), + setPrice: vi.fn(), + purchase: vi.fn(), + delete: vi.fn(), + }, + } as unknown as DashSdk, + keyManager: { getAuth: vi.fn() } as unknown as DashKeyManager, + contractId: "contract-1", + cardId: "card-1", + log: vi.fn(), +}; + +function arrangeAuthedCard() { + mockWithAuthedCard.mockImplementation( + async ( + _opts: unknown, + fn: (ctx: { + doc?: typeof doc; + identity: typeof identity; + identityKey: typeof identityKey; + signer: typeof signer; + }) => Promise, + ) => { + await fn({ doc, identity, identityKey, signer }); + }, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + arrangeAuthedCard(); +}); + +describe("card operation wrappers", () => { + it("transferCard validates the recipient before starting a mutation", async () => { + await expect( + transferCard({ ...baseParams, recipientId: "" }), + ).rejects.toThrow("Recipient identity ID is required."); + + expect(mockWithAuthedCard).not.toHaveBeenCalled(); + expect(baseParams.sdk.documents.transfer).not.toHaveBeenCalled(); + }); + + it("transferCard passes the fetched document, recipient, auth key, and signer", async () => { + await transferCard({ ...baseParams, recipientId: "recipient-1" }); + + expect(mockWithAuthedCard).toHaveBeenCalledWith( + { + sdk: baseParams.sdk, + keyManager: baseParams.keyManager, + contractId: "contract-1", + cardId: "card-1", + errorLabel: "Transfer error", + log: baseParams.log, + }, + expect.any(Function), + ); + expect(baseParams.sdk.documents.transfer).toHaveBeenCalledWith({ + document: doc, + recipientId: "recipient-1", + identityKey, + signer, + }); + expect(baseParams.log).toHaveBeenCalledWith( + "Transferring card card-1 to recipient-1…", + ); + expect(baseParams.log).toHaveBeenCalledWith("Card transferred!", "success"); + }); + + it("setPrice converts numeric prices to bigint and calls documents.setPrice", async () => { + await setPrice({ ...baseParams, price: 12345 }); + + expect(mockWithAuthedCard).toHaveBeenCalledWith( + expect.objectContaining({ + errorLabel: "Set price error", + cardId: "card-1", + }), + expect.any(Function), + ); + expect(baseParams.sdk.documents.setPrice).toHaveBeenCalledWith({ + document: doc, + price: 12345n, + identityKey, + signer, + }); + expect(baseParams.log).toHaveBeenCalledWith( + "Setting price 12345 credits on card card-1…", + ); + expect(baseParams.log).toHaveBeenCalledWith("Price set!", "success"); + }); + + it("setPrice treats a zero price as removing the sale price", async () => { + await setPrice({ ...baseParams, price: 0n }); + + expect(mockWithAuthedCard).toHaveBeenCalledWith( + expect.objectContaining({ errorLabel: "Remove price error" }), + expect.any(Function), + ); + expect(baseParams.sdk.documents.setPrice).toHaveBeenCalledWith({ + document: doc, + price: 0n, + identityKey, + signer, + }); + expect(baseParams.log).toHaveBeenCalledWith( + "Removing price from card card-1…", + ); + expect(baseParams.log).toHaveBeenCalledWith( + "Card removed from sale.", + "success", + ); + }); + + it("purchaseCard submits the current identity as buyerId and preserves bigint prices", async () => { + await purchaseCard({ ...baseParams, price: 99n }); + + expect(mockWithAuthedCard).toHaveBeenCalledWith( + expect.objectContaining({ + errorLabel: "Purchase error", + cardId: "card-1", + }), + expect.any(Function), + ); + expect(baseParams.sdk.documents.purchase).toHaveBeenCalledWith({ + document: doc, + buyerId: "buyer-identity", + price: 99n, + identityKey, + signer, + }); + expect(baseParams.log).toHaveBeenCalledWith( + "Purchasing card card-1 for 99 credits…", + ); + expect(baseParams.log).toHaveBeenCalledWith("Card purchased!", "success"); + }); + + it("burnCard skips document prefetch and sends the minimal delete document", async () => { + await burnCard(baseParams); + + expect(mockWithAuthedCard).toHaveBeenCalledWith( + { + sdk: baseParams.sdk, + keyManager: baseParams.keyManager, + contractId: "contract-1", + cardId: "card-1", + preFetch: false, + errorLabel: "Burn error", + log: baseParams.log, + }, + expect.any(Function), + ); + expect(baseParams.sdk.documents.delete).toHaveBeenCalledWith({ + document: { + id: "card-1", + ownerId: "buyer-identity", + dataContractId: "contract-1", + documentTypeName: "card", + }, + identityKey, + signer, + }); + expect(baseParams.log).toHaveBeenCalledWith("Burning card card-1…"); + expect(baseParams.log).toHaveBeenCalledWith("Card burned.", "success"); + }); +}); diff --git a/example-apps/dashmint-lab/test/UiPrimitives.test.tsx b/example-apps/dashmint-lab/test/UiPrimitives.test.tsx new file mode 100644 index 0000000..4e40cc0 --- /dev/null +++ b/example-apps/dashmint-lab/test/UiPrimitives.test.tsx @@ -0,0 +1,207 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AppShell } from "../src/components/AppShell"; +import { CardGrid } from "../src/components/CardGrid"; +import { HowItWorks } from "../src/components/HowItWorks"; +import { SubTabs } from "../src/components/Tabs"; +import type { Card } from "../src/dash/queries"; + +const { mockUseDpnsName } = vi.hoisted(() => ({ + mockUseDpnsName: vi.fn(), +})); + +vi.mock("../src/hooks/useDpnsName", () => ({ + useDpnsName: mockUseDpnsName, +})); + +vi.mock("../src/components/CardTile", () => ({ + CardTile: ({ + card, + onTransfer, + }: { + card: Card; + onTransfer?: (card: Card) => void; + }) => ( +
+

{card.data.name}

+ +
+ ), +})); + +const cards: Card[] = [ + { + id: "card-1", + ownerId: "owner-1", + data: { name: "Fire Dragon", attack: 9, defense: 8 }, + }, + { + id: "card-2", + ownerId: "owner-2", + data: { name: "Aqua Spirit", attack: 3, defense: 4 }, + }, +]; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("AppShell", () => { + it("shows sidebar login navigation when the session is not authenticated", () => { + const onLoginOpen = vi.fn(); + + render( + +
Collection content
+
, + ); + + const sidebar = screen.getByRole("complementary", { + name: "Main navigation", + }); + fireEvent.click(within(sidebar).getByRole("button", { name: /Login/ })); + + expect(onLoginOpen).toHaveBeenCalledTimes(1); + expect(screen.getByText("Collection content")).toBeTruthy(); + }); + + it("closes the mobile drawer after selecting a navigation item", () => { + const onTabChange = vi.fn(); + + render( + +
Collection content
+
, + ); + + const menuButton = screen.getByRole("button", { name: "Open menu" }); + fireEvent.click(menuButton); + expect(menuButton.getAttribute("aria-expanded")).toBe("true"); + + const sidebar = screen.getByRole("complementary", { + name: "Main navigation", + }); + fireEvent.click(within(sidebar).getByRole("button", { name: /Mint/ })); + + expect(onTabChange).toHaveBeenCalledWith("mint"); + expect(menuButton.getAttribute("aria-expanded")).toBe("false"); + }); + + it("hides login navigation and shows identity details when authenticated", () => { + mockUseDpnsName.mockReturnValue("alice"); + + render( + +
Collection content
+
, + ); + + const sidebar = screen.getByRole("complementary", { + name: "Main navigation", + }); + expect(within(sidebar).queryByRole("button", { name: /Login/ })).toBeNull(); + expect(screen.getByText("Signed in")).toBeTruthy(); + expect(screen.getByText("@alice")).toBeTruthy(); + }); +}); + +describe("SubTabs", () => { + it("hides the Yours tab for browse-only users", () => { + render(); + + expect(screen.queryByRole("button", { name: "Yours" })).toBeNull(); + expect(screen.getByRole("button", { name: "All" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Marketplace" })).toBeTruthy(); + }); + + it("emits tab changes for visible collection tabs", () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "All" })); + fireEvent.click(screen.getByRole("button", { name: "Marketplace" })); + fireEvent.click(screen.getByRole("button", { name: "Yours" })); + + expect(onChange).toHaveBeenNthCalledWith(1, "all"); + expect(onChange).toHaveBeenNthCalledWith(2, "marketplace"); + expect(onChange).toHaveBeenNthCalledWith(3, "my"); + }); +}); + +describe("CardGrid", () => { + it("renders the configured empty message when there are no cards", () => { + render( + , + ); + + expect(screen.getByText("Nothing minted yet.")).toBeTruthy(); + }); + + it("renders one CardTile per card and forwards callbacks", () => { + const onTransfer = vi.fn(); + + render( + , + ); + + expect(screen.getByText("Fire Dragon")).toBeTruthy(); + expect(screen.getByText("Aqua Spirit")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Transfer card-2" })); + + expect(onTransfer).toHaveBeenCalledWith(cards[1]); + }); +}); + +describe("HowItWorks", () => { + it("maps the mint operation to the token-paid document create call", () => { + render(); + + expect(screen.getByText("Platform operations at a glance")).toBeTruthy(); + expect(screen.getByText("Mint card")).toBeTruthy(); + expect( + screen.getByText("sdk.documents.create + tokenPaymentInfo"), + ).toBeTruthy(); + }); +}); diff --git a/example-apps/dashmint-lab/test/logger.test.ts b/example-apps/dashmint-lab/test/logger.test.ts new file mode 100644 index 0000000..d009c66 --- /dev/null +++ b/example-apps/dashmint-lab/test/logger.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { errorMessage } from "../src/dash/logger"; + +describe("errorMessage", () => { + it("reads .message from Error instances", () => { + expect(errorMessage(new Error("boom"))).toBe("boom"); + }); + + it("returns string values as-is", () => { + expect(errorMessage("already a string")).toBe("already a string"); + }); + + it("reads .message from plain objects (Evo SDK throws these)", () => { + expect(errorMessage({ message: "wasm failed", code: 7 })).toBe( + "wasm failed", + ); + }); + + it("JSON-stringifies objects without a string message", () => { + expect(errorMessage({ code: 7, detail: "nope" })).toBe( + '{"code":7,"detail":"nope"}', + ); + }); + + it("falls back to String() when JSON.stringify throws (circular refs)", () => { + const circular: Record = {}; + circular.self = circular; + expect(errorMessage(circular)).toBe("[object Object]"); + }); + + it("handles primitives that are neither Error nor string", () => { + expect(errorMessage(42)).toBe("42"); + expect(errorMessage(null)).toBe("null"); + }); +}); diff --git a/example-apps/dashmint-lab/test/queries.test.ts b/example-apps/dashmint-lab/test/queries.test.ts new file mode 100644 index 0000000..10b07f4 --- /dev/null +++ b/example-apps/dashmint-lab/test/queries.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + listAllCards, + listMarketplaceCards, + listMyCards, + normalizeCards, +} from "../src/dash/queries"; +import type { DashSdk } from "../src/dash/types"; + +const rawCards = { + "doc-1": { + $ownerId: "owner-1", + name: "Fire Dragon", + description: "Hot", + attack: 9, + defense: 8, + $price: 50n, + }, + "doc-2": { + $ownerId: "owner-2", + name: "Aqua Spirit", + attack: 3, + defense: 4, + }, + "doc-3": { + $ownerId: "owner-3", + name: "Unlisted Golem", + attack: 4, + defense: 9, + $price: 0n, + }, +}; + +function makeSdk(results: unknown): DashSdk { + return { + documents: { + query: vi.fn().mockResolvedValue(results), + }, + } as unknown as DashSdk; +} + +let log: ReturnType; + +beforeEach(() => { + log = vi.fn(); +}); + +describe("card queries", () => { + it("normalizes plain object query results keyed by document id", () => { + expect(normalizeCards(rawCards)).toEqual([ + { + id: "doc-1", + ownerId: "owner-1", + data: { + name: "Fire Dragon", + description: "Hot", + attack: 9, + defense: 8, + }, + $price: 50n, + }, + { + id: "doc-2", + ownerId: "owner-2", + data: { + name: "Aqua Spirit", + description: undefined, + attack: 3, + defense: 4, + }, + $price: undefined, + }, + { + id: "doc-3", + ownerId: "owner-3", + data: { + name: "Unlisted Golem", + description: undefined, + attack: 4, + defense: 9, + }, + $price: 0n, + }, + ]); + }); + + it("listMyCards queries the owner-scoped collection", async () => { + const sdk = makeSdk(rawCards); + + const cards = await listMyCards({ + sdk, + contractId: "contract-1", + identityId: "owner-1", + log, + }); + + expect(sdk.documents.query).toHaveBeenCalledWith({ + dataContractId: "contract-1", + documentTypeName: "card", + where: [["$ownerId", "==", "owner-1"]], + limit: 100, + }); + expect(cards).toHaveLength(3); + expect(log).toHaveBeenCalledWith("Loading your cards…"); + expect(log).toHaveBeenCalledWith("Found 3 card(s)."); + }); + + it("listAllCards uses the caller-provided limit", async () => { + const sdk = makeSdk([]); + + await expect( + listAllCards({ + sdk, + contractId: "contract-1", + limit: 25, + log, + }), + ).resolves.toEqual([]); + + expect(sdk.documents.query).toHaveBeenCalledWith({ + dataContractId: "contract-1", + documentTypeName: "card", + limit: 25, + }); + expect(log).toHaveBeenCalledWith("Loading all cards (any owner)…"); + expect(log).toHaveBeenCalledWith("Found 0 card(s) total."); + }); + + it("listMarketplaceCards returns only cards with a non-zero sale price", async () => { + const sdk = makeSdk(rawCards); + + const cards = await listMarketplaceCards({ + sdk, + contractId: "contract-1", + log, + }); + + expect(sdk.documents.query).toHaveBeenCalledWith({ + dataContractId: "contract-1", + documentTypeName: "card", + limit: 100, + }); + expect(cards.map((card) => card.id)).toEqual(["doc-1"]); + expect(log).toHaveBeenCalledWith("Loading marketplace…"); + expect(log).toHaveBeenCalledWith("Found 1 card(s) for sale."); + }); +}); diff --git a/example-apps/dashmint-lab/test/useDpnsName.test.tsx b/example-apps/dashmint-lab/test/useDpnsName.test.tsx new file mode 100644 index 0000000..c2849d9 --- /dev/null +++ b/example-apps/dashmint-lab/test/useDpnsName.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useDpnsName } from "../src/hooks/useDpnsName"; +import type { DashSdk } from "../src/dash/types"; + +function makeSdk(username: ReturnType): DashSdk { + return { + dpns: { + username, + resolveName: vi.fn(), + }, + } as unknown as DashSdk; +} + +function Probe({ + sdk, + identityId, +}: { + sdk?: DashSdk | null; + identityId?: string | null; +}) { + const name = useDpnsName(sdk, identityId); + return
{name ?? ""}
; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useDpnsName", () => { + it("does not resolve until both sdk and identity id are available", () => { + const username = vi.fn(); + const sdk = makeSdk(username); + + render(); + + expect(screen.getByTestId("name").textContent).toBe(""); + expect(username).not.toHaveBeenCalled(); + }); + + it("resolves an identity id and strips the display-only .dash suffix", async () => { + const username = vi.fn().mockResolvedValue("alice.dash"); + const sdk = makeSdk(username); + + render(); + + expect(screen.getByTestId("name").textContent).toBe(""); + await waitFor(() => { + expect(screen.getByTestId("name").textContent).toBe("alice"); + }); + expect(username).toHaveBeenCalledWith("identity-use-dpns-name-1"); + }); + + it("keeps names that do not end in the display-only .dash suffix", async () => { + const username = vi.fn().mockResolvedValue("alice"); + const sdk = makeSdk(username); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("name").textContent).toBe("alice"); + }); + }); + + it.each([ + ["missing", undefined], + ["numeric", 123], + ["object", { label: "alice.dash" }], + ])("treats %s DPNS responses as no display name", async (_case, value) => { + const username = vi.fn().mockResolvedValue(value); + const sdk = makeSdk(username); + + render(); + + await waitFor(() => { + expect(username).toHaveBeenCalledTimes(1); + }); + expect(screen.getByTestId("name").textContent).toBe(""); + }); + + it("reuses the module-level cache across hook mounts", async () => { + const username = vi.fn().mockResolvedValue("cached-name.dash"); + const sdk = makeSdk(username); + + const first = render( + , + ); + await waitFor(() => { + expect(screen.getByTestId("name").textContent).toBe("cached-name"); + }); + first.unmount(); + + render(); + + expect(screen.getByTestId("name").textContent).toBe("cached-name"); + expect(username).toHaveBeenCalledTimes(1); + }); + + it("shares an in-flight lookup across sibling hook instances", async () => { + const username = vi.fn().mockResolvedValue("shared-name.dash"); + const sdk = makeSdk(username); + + function Pair() { + return ( + <> + + + + ); + } + + render(); + + await waitFor(() => { + expect(screen.getAllByTestId("name").map((el) => el.textContent)).toEqual( + ["shared-name", "shared-name"], + ); + }); + expect(username).toHaveBeenCalledTimes(1); + }); +}); diff --git a/example-apps/dashmint-lab/test/useResolvedRecipient.test.tsx b/example-apps/dashmint-lab/test/useResolvedRecipient.test.tsx new file mode 100644 index 0000000..b9ef7ea --- /dev/null +++ b/example-apps/dashmint-lab/test/useResolvedRecipient.test.tsx @@ -0,0 +1,224 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { DashSdk } from "../src/dash/types"; +import { useResolvedRecipient } from "../src/hooks/useResolvedRecipient"; + +const { mockResolveDpnsName } = vi.hoisted(() => ({ + mockResolveDpnsName: vi.fn(), +})); + +vi.mock("../src/dash/resolveRecipient", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolveDpnsName: mockResolveDpnsName, + }; +}); + +const sdk = { + dpns: { + resolveName: vi.fn(), + username: vi.fn(), + }, +} as unknown as DashSdk; + +function Probe({ + currentSdk = sdk, + input, +}: { + currentSdk?: DashSdk | null; + input?: string | null; +}) { + const state = useResolvedRecipient(currentSdk, input); + return
{JSON.stringify(state)}
; +} + +function state() { + return JSON.parse(screen.getByTestId("state").textContent ?? "{}"); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useResolvedRecipient", () => { + it("stays idle when there is no sdk or resolvable input", () => { + render(); + expect(state()).toEqual({ status: "idle" }); + expect(mockResolveDpnsName).not.toHaveBeenCalled(); + + cleanup(); + + render(); + expect(state()).toEqual({ status: "idle" }); + expect(mockResolveDpnsName).not.toHaveBeenCalled(); + }); + + it("normalizes a DPNS name, resolves it, and reports the identity id", async () => { + mockResolveDpnsName.mockResolvedValueOnce("identity-recipient-1"); + + render(); + + expect(state()).toEqual({ status: "resolving" }); + await waitFor(() => { + expect(state()).toEqual({ + status: "resolved", + identityId: "identity-recipient-1", + }); + }); + expect(mockResolveDpnsName).toHaveBeenCalledWith(sdk, "alice.dash"); + }); + + it("reports not-found when DPNS resolution completes without an identity", async () => { + mockResolveDpnsName.mockResolvedValueOnce(null); + + render(); + + await waitFor(() => { + expect(state()).toEqual({ status: "not-found" }); + }); + expect(mockResolveDpnsName).toHaveBeenCalledWith( + sdk, + "missing-recipient-name.dash", + ); + }); + + it("reuses cached not-found results across hook mounts", async () => { + mockResolveDpnsName.mockResolvedValueOnce(null); + + const first = render(); + await waitFor(() => { + expect(state()).toEqual({ status: "not-found" }); + }); + first.unmount(); + + render(); + + expect(state()).toEqual({ status: "not-found" }); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + }); + + it("reuses cached resolved names across hook mounts", async () => { + mockResolveDpnsName.mockResolvedValueOnce("identity-recipient-cache"); + + const first = render(); + await waitFor(() => { + expect(state()).toEqual({ + status: "resolved", + identityId: "identity-recipient-cache", + }); + }); + first.unmount(); + + render(); + + expect(state()).toEqual({ + status: "resolved", + identityId: "identity-recipient-cache", + }); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + }); + + it("does not cache transient resolver errors across mounts", async () => { + mockResolveDpnsName + .mockRejectedValueOnce(new Error("temporary network failure")) + .mockResolvedValueOnce("identity-recipient-retry"); + + const first = render(); + await waitFor(() => { + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + }); + first.unmount(); + + render(); + + await waitFor(() => { + expect(state()).toEqual({ + status: "resolved", + identityId: "identity-recipient-retry", + }); + }); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(2); + }); + + it("shares one in-flight lookup across sibling hook instances", async () => { + // Hold the lookup pending so the second sibling's effect runs while the + // cache entry is still a Promise, exercising the shared-promise branch. + let resolveLookup: (value: string) => void = () => {}; + mockResolveDpnsName.mockReturnValueOnce( + new Promise((resolve) => { + resolveLookup = resolve; + }), + ); + + function Pair() { + return ( + <> + + + + ); + } + + render(); + + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + expect(screen.getAllByTestId("state").map((el) => el.textContent)).toEqual([ + JSON.stringify({ status: "resolving" }), + JSON.stringify({ status: "resolving" }), + ]); + + resolveLookup("identity-recipient-shared"); + + await waitFor(() => { + expect( + screen + .getAllByTestId("state") + .map((el) => JSON.parse(el.textContent ?? "{}")), + ).toEqual([ + { status: "resolved", identityId: "identity-recipient-shared" }, + { status: "resolved", identityId: "identity-recipient-shared" }, + ]); + }); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + }); + + it("propagates a rejected shared lookup to sibling hooks", async () => { + let rejectLookup: (reason: Error) => void = () => {}; + mockResolveDpnsName.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectLookup = reject; + }), + ); + + function Pair() { + return ( + <> + + + + ); + } + + render(); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + + rejectLookup(new Error("shared lookup failed")); + + // Both siblings re-render after the rejection; the dropped cache entry + // returns them to "resolving"; a later remount or key/sdk change can retry. + await waitFor(() => { + expect( + screen.getAllByTestId("state").map((el) => el.textContent), + ).toEqual([ + JSON.stringify({ status: "resolving" }), + JSON.stringify({ status: "resolving" }), + ]); + }); + }); +}); From 4c8dad8094a9937a3ec2f0839c5ea51453757dfa Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 9 Jun 2026 13:53:34 -0400 Subject: [PATCH 4/5] fix(dashmint-lab): handle card prices as exact bigints Validate the price input as a string and convert with BigInt so values are never rounded through Number before the serialization-limit check, and submit the bigint to setPrice. Sort the collection by exact bigint price value so prices above Number.MAX_SAFE_INTEGER order correctly, and filter the marketplace with an explicit sale-price check instead of relying on falsy coercion. Co-Authored-By: Claude Opus 4.8 (1M context) --- example-apps/dashmint-lab/src/App.tsx | 15 ++++++-- .../src/components/SetPriceModal.tsx | 11 +++--- example-apps/dashmint-lab/src/dash/queries.ts | 6 +++- example-apps/dashmint-lab/test/App.test.tsx | 36 +++++++++++++++++++ .../dashmint-lab/test/SetPriceModal.test.tsx | 20 ++++++++++- 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/example-apps/dashmint-lab/src/App.tsx b/example-apps/dashmint-lab/src/App.tsx index cb3e88d..d0fad4f 100644 --- a/example-apps/dashmint-lab/src/App.tsx +++ b/example-apps/dashmint-lab/src/App.tsx @@ -38,6 +38,12 @@ const SORT_LABELS: Record = { }; const SORT_ORDER: SortKey[] = ["rarity", "name", "owner", "price"]; +function cardPriceValue(card: Card): bigint | null { + const price = card.$price; + if (price === undefined || price === 0 || price === 0n) return null; + return typeof price === "bigint" ? price : BigInt(Math.trunc(price)); +} + function App() { const session = useSession(); const { @@ -154,9 +160,12 @@ function App() { ); } else if (sortKey === "price") { return [...cards].sort((a, b) => { - const pa = a.$price ? Number(a.$price) : -1; - const pb = b.$price ? Number(b.$price) : -1; - return pb - pa; + const pa = cardPriceValue(a); + const pb = cardPriceValue(b); + if (pa === pb) return 0; + if (pa === null) return 1; + if (pb === null) return -1; + return pa > pb ? -1 : 1; }); } return cards; diff --git a/example-apps/dashmint-lab/src/components/SetPriceModal.tsx b/example-apps/dashmint-lab/src/components/SetPriceModal.tsx index bd3ecb7..2436c70 100644 --- a/example-apps/dashmint-lab/src/components/SetPriceModal.tsx +++ b/example-apps/dashmint-lab/src/components/SetPriceModal.tsx @@ -21,6 +21,7 @@ const SUCCESS_CLOSE_DELAY_MS = 700; // TODO(dashpay/platform#3786): Remove this app-level cap after the SDK can // safely serialize document prices above Number.MAX_SAFE_INTEGER. export const MAX_PRICE_CREDITS = 1_000_000_000_000_000; +const MAX_PRICE_CREDITS_BIGINT = BigInt(MAX_PRICE_CREDITS); const MAX_PRICE_ERROR = `Price must be between 1 and ${formatCredits(MAX_PRICE_CREDITS)} credits.`; export function SetPriceModal({ card, onClose, onPriced }: SetPriceModalProps) { @@ -71,13 +72,15 @@ export function SetPriceModal({ card, onClose, onPriced }: SetPriceModalProps) { async function handleSubmit(e: FormEvent) { e.preventDefault(); const value = amount.trim(); - const n = Number(value); - if (!/^\d+$/.test(value) || !Number.isFinite(n) || n < 1) return; - if (n > MAX_PRICE_CREDITS) { + if (!/^\d+$/.test(value)) return; + + const price = BigInt(value); + if (price < 1n) return; + if (price > MAX_PRICE_CREDITS_BIGINT) { setResult({ kind: "error", message: MAX_PRICE_ERROR }); return; } - await submitPrice(n); + await submitPrice(price); } const hasCurrentPrice = !!card && !!card.$price; diff --git a/example-apps/dashmint-lab/src/dash/queries.ts b/example-apps/dashmint-lab/src/dash/queries.ts index 42346c9..5730859 100644 --- a/example-apps/dashmint-lab/src/dash/queries.ts +++ b/example-apps/dashmint-lab/src/dash/queries.ts @@ -34,6 +34,10 @@ export interface Card { $price?: number | bigint; } +function hasSalePrice(card: Card): boolean { + return card.$price != null && card.$price !== 0 && card.$price !== 0n; +} + function toCard(id: string | null, raw: DashCardQueryDocument): Card { const j: Record = typeof raw?.toJSON === "function" ? raw.toJSON() : raw; @@ -112,7 +116,7 @@ export async function listMarketplaceCards({ documentTypeName: "card", limit, }); - const cards = normalizeCards(results).filter((c) => c.$price); + const cards = normalizeCards(results).filter(hasSalePrice); log?.(`Found ${cards.length} card(s) for sale.`); return cards; } diff --git a/example-apps/dashmint-lab/test/App.test.tsx b/example-apps/dashmint-lab/test/App.test.tsx index 953c4c0..17939e5 100644 --- a/example-apps/dashmint-lab/test/App.test.tsx +++ b/example-apps/dashmint-lab/test/App.test.tsx @@ -433,6 +433,42 @@ describe("App", () => { ); }); + it("sorts prices by exact bigint value above Number.MAX_SAFE_INTEGER", async () => { + const session = makeSession(); + const largePriceCards: Card[] = [ + { + id: "lower", + ownerId: "owner-1", + data: { name: "Lower Price", attack: 1, defense: 1 }, + $price: 9_007_199_254_740_992n, + }, + { + id: "higher", + ownerId: "owner-2", + data: { name: "Higher Price", attack: 1, defense: 1 }, + $price: 9_007_199_254_740_993n, + }, + ]; + mockUseSession.mockReturnValue(session); + mockListAllCards.mockResolvedValue(largePriceCards); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("cards").textContent).toBe( + "Lower Price|Higher Price", + ); + }); + + fireEvent.click(screen.getByRole("button", { name: "Sort: Rarity" })); + fireEvent.click(screen.getByRole("button", { name: "Sort: Name" })); + fireEvent.click(screen.getByRole("button", { name: "Sort: Owner" })); + + expect(screen.getByTestId("cards").textContent).toBe( + "Higher Price|Lower Price", + ); + }); + it("wires modal state into child props for login and card actions", async () => { const session = makeSession(); mockUseSession.mockReturnValue(session); diff --git a/example-apps/dashmint-lab/test/SetPriceModal.test.tsx b/example-apps/dashmint-lab/test/SetPriceModal.test.tsx index e966d74..a3fd225 100644 --- a/example-apps/dashmint-lab/test/SetPriceModal.test.tsx +++ b/example-apps/dashmint-lab/test/SetPriceModal.test.tsx @@ -114,7 +114,7 @@ describe("SetPriceModal", () => { keyManager: sessionValue.keyManager, contractId: "contract-1", cardId: "card-1", - price: 42, + price: 42n, log: sessionValue.log, }); @@ -150,6 +150,24 @@ describe("SetPriceModal", () => { ); }); + it("submits the maximum accepted price as an exact bigint", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockSetPrice.mockResolvedValueOnce(undefined); + + render(); + + fireEvent.change(screen.getByLabelText("Price"), { + target: { value: String(MAX_PRICE_CREDITS) }, + }); + fireEvent.click(screen.getByRole("button", { name: "List for sale" })); + + expect(mockSetPrice).toHaveBeenCalledWith( + expect.objectContaining({ + price: BigInt(MAX_PRICE_CREDITS), + }), + ); + }); + it("treats $price === 0n as unlisted (zero is not a valid price)", () => { mockUseSession.mockReturnValue(sessionValue); From 7b52f7aa118bbc15b9400d4599b9db6bf5c37a08 Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 9 Jun 2026 14:03:22 -0400 Subject: [PATCH 5/5] feat(dashmint-lab): warn when credits are too low to buy a card The purchase modal previously always enabled the Buy button, so an under-funded buyer only learned they could not afford a card from the raw SDK error after clicking. Mirror the MintForm pattern: show the buyer's credit balance, warn when it is below the card price, and disable the Buy button (relabeled "Insufficient credits") up front. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/PurchaseModal.tsx | 36 ++++++++++++++-- .../dashmint-lab/test/PurchaseModal.test.tsx | 41 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/example-apps/dashmint-lab/src/components/PurchaseModal.tsx b/example-apps/dashmint-lab/src/components/PurchaseModal.tsx index e9e9666..e0955b7 100644 --- a/example-apps/dashmint-lab/src/components/PurchaseModal.tsx +++ b/example-apps/dashmint-lab/src/components/PurchaseModal.tsx @@ -28,6 +28,10 @@ export function PurchaseModal({ const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState(null); + const price = card?.$price ?? null; + const insufficientCredits = + session.balance !== null && price !== null && session.balance < price; + useEffect(() => { if (card) { setResult(null); @@ -42,7 +46,8 @@ export function PurchaseModal({ !session.keyManager || !session.contractId || card.$price === undefined || - card.$price === null + card.$price === null || + insufficientCredits ) return; setSubmitting(true); @@ -82,16 +87,41 @@ export function PurchaseModal({
+
+ + Your balance + + + {session.balance === null + ? "—" + : `${formatCredits(session.balance)} credits`} + +
+ + {insufficientCredits && ( +

+ Not enough credits to buy this card. +

+ )} + {result && }