From 7e9fff781ab863b39a2f9d7bee1d08b437071358 Mon Sep 17 00:00:00 2001 From: thephez Date: Wed, 3 Jun 2026 17:00:23 -0400 Subject: [PATCH 1/4] feat(dashmint-lab): add Tokens tab for DashMint token transfers Adds a new Tokens screen with a transfer flow, wires up sdk.tokens.transfer + keyManager.getTransfer in shared types, and refreshes the token balance when the tab activates. Co-Authored-By: Claude Opus 4.7 (1M context) --- example-apps/dashmint-lab/eslint.config.js | 2 +- example-apps/dashmint-lab/src/App.tsx | 38 ++- .../dashmint-lab/src/components/AppShell.tsx | 9 + .../dashmint-lab/src/components/Tabs.tsx | 2 +- .../src/components/TokenTransferScreen.tsx | 314 ++++++++++++++++++ .../src/dash/transferDashMintTokens.ts | 68 ++++ example-apps/dashmint-lab/src/dash/types.ts | 10 + example-apps/dashmint-lab/test/App.test.tsx | 59 +++- .../test/TokenTransferScreen.test.tsx | 233 +++++++++++++ example-apps/dashmint-lab/test/dash.test.ts | 12 + .../test/transferDashMintTokens.test.ts | 121 +++++++ setupDashClient-core.d.mts | 14 + 12 files changed, 877 insertions(+), 5 deletions(-) create mode 100644 example-apps/dashmint-lab/src/components/TokenTransferScreen.tsx create mode 100644 example-apps/dashmint-lab/src/dash/transferDashMintTokens.ts create mode 100644 example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx create mode 100644 example-apps/dashmint-lab/test/transferDashMintTokens.test.ts diff --git a/example-apps/dashmint-lab/eslint.config.js b/example-apps/dashmint-lab/eslint.config.js index 75d3c46..4eff34b 100644 --- a/example-apps/dashmint-lab/eslint.config.js +++ b/example-apps/dashmint-lab/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from "typescript-eslint"; import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(["dist"]), + globalIgnores(["coverage", "dist"]), { files: ["**/*.{ts,tsx}"], extends: [ diff --git a/example-apps/dashmint-lab/src/App.tsx b/example-apps/dashmint-lab/src/App.tsx index e6b9c5d..cb3e88d 100644 --- a/example-apps/dashmint-lab/src/App.tsx +++ b/example-apps/dashmint-lab/src/App.tsx @@ -22,6 +22,7 @@ import { MintForm } from "./components/MintForm"; import { PurchaseModal } from "./components/PurchaseModal"; import { SetPriceModal } from "./components/SetPriceModal"; import { SubTabs, type CollectionSubTab, type TopTab } from "./components/Tabs"; +import { TokenTransferScreen } from "./components/TokenTransferScreen"; import { TransferModal } from "./components/TransferModal"; import { HowItWorks } from "./components/HowItWorks"; import { errorMessage } from "./dash/logger"; @@ -89,12 +90,14 @@ function App() { else if (status === "browsing") setSubTab("all"); }, [status]); - // Re-fetch token balance whenever the Mint tab becomes active. The balance + // Re-fetch token balance whenever token-related tabs become active. The balance // effect in SessionContext only runs on login/logout/contract change, so // without this prompt the value could be stale (read-after-write lag from // a recent mint elsewhere, or simply a value from many minutes ago). useEffect(() => { - if (tab === "mint" && status === "authenticated") refreshBalance(); + if ((tab === "mint" || tab === "tokens") && status === "authenticated") { + refreshBalance(); + } }, [tab, status, refreshBalance]); // Load cards for the current sub-tab whenever dependencies change. @@ -173,6 +176,10 @@ function App() { subtitle: "Create collectible cards on Dash Platform using DashMint tokens.", }, + tokens: { + title: "Tokens", + subtitle: "Send DashMint tokens to another identity on Dash Platform.", + }, "how-it-works": { title: "How it works", subtitle: "Understand the building blocks behind DashMint Lab.", @@ -303,6 +310,33 @@ function App() { )} + {/* ── Tokens ────────────────────────────────────────────────── */} + {tab === "tokens" && ( +
+ {status !== "authenticated" && ( +
+

+ Login to transfer DashMint tokens +

+ +
+ )} + {contractId && ( + + )} +
+ )} + {/* ── How it works ──────────────────────────────────────────── */} {tab === "how-it-works" && (
diff --git a/example-apps/dashmint-lab/src/components/AppShell.tsx b/example-apps/dashmint-lab/src/components/AppShell.tsx index 9eb96e0..bcbe863 100644 --- a/example-apps/dashmint-lab/src/components/AppShell.tsx +++ b/example-apps/dashmint-lab/src/components/AppShell.tsx @@ -63,6 +63,15 @@ export function AppShell({ closeDrawer(); }} /> + { + onTabChange("tokens"); + closeDrawer(); + }} + /> void; +} + +export function TokenTransferScreen({ + contractId, + dashMintTokenBalance = null, + onTransferred, +}: TokenTransferScreenProps) { + const session = useSession(); + const [recipient, setRecipient] = useState(""); + const [amountInput, setAmountInput] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + + const trimmedRecipient = recipient.trim(); + const recipientMode: RecipientMode = trimmedRecipient + ? classifyRecipientInput(trimmedRecipient) + : "invalid"; + const resolved = useResolvedRecipient( + session.sdk, + recipientMode === "name" || recipientMode === "ambiguous" + ? trimmedRecipient + : null, + ); + const idForReverse = + recipientMode === "ambiguous" && resolved.status === "not-found" + ? trimmedRecipient + : null; + const reverseName = useDpnsName(session.sdk, idForReverse); + + const amount = parseWholeTokenAmount(amountInput); + const amountError = amountInput.trim() + ? amount === null + ? "Enter a positive whole amount." + : dashMintTokenBalance !== null && amount > dashMintTokenBalance + ? `Insufficient ${DASHMINT_TOKEN_NAME} balance.` + : null + : null; + const resolvedId = + resolved.status === "resolved" ? resolved.identityId : null; + const nameBlocksSubmit = + recipientMode === "name" && resolved.status !== "resolved"; + const ambiguousResolving = + recipientMode === "ambiguous" && resolved.status === "resolving"; + const canSubmit = + !!session.sdk && + !!session.keyManager && + !!trimmedRecipient && + recipientMode !== "invalid" && + amount !== null && + amountError === null && + !nameBlocksSubmit && + !ambiguousResolving; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!session.sdk || !session.keyManager || !canSubmit || amount === null) { + return; + } + + const recipientId = resolvedId ?? trimmedRecipient; + setSubmitting(true); + setResult(null); + try { + await transferDashMintTokens({ + sdk: session.sdk, + keyManager: session.keyManager, + contractId, + recipientId, + amount, + availableBalance: dashMintTokenBalance, + log: session.log, + }); + setResult({ + kind: "success", + message: `${DASHMINT_TOKEN_NAME} tokens transferred successfully.`, + }); + setRecipient(""); + setAmountInput(""); + onTransferred?.(); + } catch (err) { + setResult({ kind: "error", message: errorMessage(err) }); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+ Your {DASHMINT_TOKEN_NAME} balance +
+
+ {dashMintTokenBalance === null + ? "Unavailable" + : dashMintTokenBalance.toString()} +
+

+ {DASHMINT_TOKEN_PLURAL} tokens are spent to mint cards and can be sent + to another identity on the active testnet contract. +

+
+ +
+
+

+ Transfer tokens +

+
+ + + + + + {trimmedRecipient && recipientMode !== "invalid" && amount !== null && ( +

+ Sending{" "} + {amount.toString()}{" "} + {DASHMINT_TOKEN_PLURAL} to{" "} + +

+ )} + + {result && } + +
+ + +
+ +
+ ); +} + +function parseWholeTokenAmount(input: string): bigint | null { + const trimmed = input.trim(); + if (!/^[0-9]+$/.test(trimmed)) return null; + const amount = BigInt(trimmed); + return amount > 0n ? amount : null; +} + +interface HintProps { + mode: RecipientMode; + resolved: ReturnType; + reverseName: string | null; + trimmed: string; +} + +function RecipientHint({ mode, resolved, reverseName, trimmed }: HintProps) { + if (!trimmed) return null; + + if (mode === "invalid") { + return ( + + Enter an identity ID or DPNS name. + + ); + } + + if (resolved.status === "resolving") { + return Resolving...; + } + + if (resolved.status === "resolved") { + return ( + + Resolved + + ); + } + + if (mode === "name") { + return ( + + No identity found for “{normalizeDpnsName(trimmed)}”. + + ); + } + + if (reverseName) { + return ( + + Resolved {reverseName}.dash + + ); + } + + return null; +} + +function TransferTarget({ mode, resolved, reverseName, trimmed }: HintProps) { + if (resolved.status === "resolved") { + return ( + + {normalizeDpnsName(trimmed)}{" "} + + () + + + ); + } + + if (mode === "ambiguous" && reverseName) { + return ( + + {reverseName}.dash{" "} + + () + + + ); + } + + if (mode === "ambiguous") { + return ( + + + + ); + } + + return {truncateId(trimmed)}; +} diff --git a/example-apps/dashmint-lab/src/dash/transferDashMintTokens.ts b/example-apps/dashmint-lab/src/dash/transferDashMintTokens.ts new file mode 100644 index 0000000..4235e60 --- /dev/null +++ b/example-apps/dashmint-lab/src/dash/transferDashMintTokens.ts @@ -0,0 +1,68 @@ +/** + * Transfer DashMint tokens from the signed-in identity to another identity. + * + * DashMint lives at token position 0 on the active app contract. Token + * transfers use the identity transfer key, unlike card document operations. + * + * SDK method: sdk.tokens.transfer({ dataContractId, tokenPosition, amount, senderId, recipientId, identityKey, signer }) + */ +import { DASHMINT_TOKEN_NAME, DASHMINT_TOKEN_POSITION } from "./dashMintToken"; +import type { Logger } from "./logger"; +import type { DashKeyManager, DashSdk } from "./types"; + +export interface TransferDashMintTokensInput { + sdk: DashSdk; + keyManager: DashKeyManager; + contractId: string; + recipientId: string; + amount: bigint; + availableBalance?: bigint | null; + log?: Logger; +} + +export async function transferDashMintTokens({ + sdk, + keyManager, + contractId, + recipientId, + amount, + availableBalance, + log, +}: TransferDashMintTokensInput): Promise { + const trimmedRecipientId = recipientId.trim(); + if (!trimmedRecipientId) { + throw new Error("Recipient identity ID is required."); + } + if (amount <= 0n) { + throw new Error("Amount must be greater than 0."); + } + if (availableBalance !== null && availableBalance !== undefined) { + if (amount > availableBalance) { + throw new Error(`Not enough ${DASHMINT_TOKEN_NAME} tokens.`); + } + } + + const { identity, identityKey, signer } = await keyManager.getTransfer(); + const senderId = identity.id.toString(); + if (trimmedRecipientId === senderId) { + throw new Error("Cannot transfer tokens to yourself."); + } + + log?.( + `Transferring ${amount.toString()} ${DASHMINT_TOKEN_NAME} token${ + amount === 1n ? "" : "s" + }...`, + ); + + await sdk.tokens.transfer({ + dataContractId: contractId, + tokenPosition: DASHMINT_TOKEN_POSITION, + amount, + senderId, + recipientId: trimmedRecipientId, + identityKey, + signer, + }); + + log?.(`${DASHMINT_TOKEN_NAME} tokens transferred.`, "success"); +} diff --git a/example-apps/dashmint-lab/src/dash/types.ts b/example-apps/dashmint-lab/src/dash/types.ts index 43f5b9c..f6e0f49 100644 --- a/example-apps/dashmint-lab/src/dash/types.ts +++ b/example-apps/dashmint-lab/src/dash/types.ts @@ -13,6 +13,7 @@ export interface DashAuth { export interface DashKeyManager { readonly identityId: string | null | undefined; getAuth(): Promise; + getTransfer(): Promise; } export interface DashDocumentLike { @@ -111,6 +112,15 @@ export interface DashSdk { totalSupply( tokenId: string, ): Promise<{ totalSupply: bigint; tokenId: string } | undefined>; + transfer(args: { + dataContractId: string; + tokenPosition: number; + amount: bigint; + senderId: string; + recipientId: string; + identityKey: IdentityPublicKey | undefined; + signer: IdentitySigner; + }): Promise; }; dpns: { username(identityId: string): Promise; diff --git a/example-apps/dashmint-lab/test/App.test.tsx b/example-apps/dashmint-lab/test/App.test.tsx index e508637..953c4c0 100644 --- a/example-apps/dashmint-lab/test/App.test.tsx +++ b/example-apps/dashmint-lab/test/App.test.tsx @@ -16,6 +16,7 @@ import type { TransferModalProps } from "../src/components/TransferModal"; import type { SetPriceModalProps } from "../src/components/SetPriceModal"; import type { PurchaseModalProps } from "../src/components/PurchaseModal"; import type { BurnModalProps } from "../src/components/BurnModal"; +import type { TokenTransferScreenProps } from "../src/components/TokenTransferScreen"; const { mockUseSession, @@ -51,7 +52,9 @@ vi.mock("../src/components/AppShell", () => ({ }: { children: React.ReactNode; onLoginOpen: () => void; - onTabChange: (tab: "collection" | "mint" | "how-it-works") => void; + onTabChange: ( + tab: "collection" | "mint" | "tokens" | "how-it-works", + ) => void; }) => (
+ @@ -211,6 +217,20 @@ vi.mock("../src/components/MintForm", () => ({ }) =>
Mint Form tokens:{String(dashMintTokenBalance)}
, })); +vi.mock("../src/components/TokenTransferScreen", () => ({ + TokenTransferScreen: ({ + dashMintTokenBalance, + onTransferred, + }: TokenTransferScreenProps) => ( +
+ Token Transfer Screen tokens:{String(dashMintTokenBalance)} + +
+ ), +})); + vi.mock("../src/components/HowItWorks", () => ({ HowItWorks: () =>
How It Works
, })); @@ -560,6 +580,43 @@ describe("App", () => { ).toBeNull(); }); + it("shows the login gate on the tokens screen when not authenticated", async () => { + const session = makeSession({ status: "browsing" as const }); + mockUseSession.mockReturnValue(session); + mockListAllCards.mockResolvedValue(cards); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Tokens Tab" })); + + expect(screen.getByText("Login to transfer DashMint tokens")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Login" })); + expect(screen.getByTestId("login-modal").textContent).toContain( + "open:true", + ); + expect(screen.getByText("Token Transfer Screen tokens:null")).toBeTruthy(); + }); + + it("renders the token transfer screen and refreshes after token transfers", async () => { + const session = makeSession({ + status: "authenticated" as const, + identityId: "identity-1", + dashMintTokenBalance: 7n, + }); + mockUseSession.mockReturnValue(session); + mockListMyCards.mockResolvedValue([cards[1]]); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Tokens Tab" })); + + expect(screen.getByText("Token Transfer Screen tokens:7")).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { name: "Trigger Token Transfer Refresh" }), + ); + expect(session.refreshBalance).toHaveBeenCalled(); + }); + it("does not show a gate on the mint screen for the contract owner", async () => { const session = makeSession({ status: "authenticated" as const, diff --git a/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx b/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx new file mode 100644 index 0000000..192b523 --- /dev/null +++ b/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx @@ -0,0 +1,233 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { TokenTransferScreen } from "../src/components/TokenTransferScreen"; +import type { DashKeyManager, DashSdk } from "../src/dash/types"; +import type { ResolvedRecipient } from "../src/hooks/useResolvedRecipient"; + +const { + mockUseSession, + mockTransferDashMintTokens, + mockUseDpnsName, + mockUseResolvedRecipient, +} = vi.hoisted(() => ({ + mockUseSession: vi.fn(), + mockTransferDashMintTokens: vi.fn(), + mockUseDpnsName: vi.fn(), + mockUseResolvedRecipient: vi.fn(), +})); + +vi.mock("../src/session/useSession", () => ({ + useSession: mockUseSession, +})); + +vi.mock("../src/dash/transferDashMintTokens", () => ({ + transferDashMintTokens: mockTransferDashMintTokens, +})); + +vi.mock("../src/hooks/useDpnsName", () => ({ + useDpnsName: mockUseDpnsName, +})); + +vi.mock("../src/hooks/useResolvedRecipient", () => ({ + useResolvedRecipient: mockUseResolvedRecipient, +})); + +const sessionValue = { + sdk: {} as DashSdk, + keyManager: {} as DashKeyManager, + log: vi.fn(), +}; + +const SAMPLE_ID = "5LmvdJbGAtnk2Z3y5bwa2YcX9hk5GhVePtkT21a2mxAn"; +const idle: ResolvedRecipient = { status: "idle" }; +function resolved(identityId: string): ResolvedRecipient { + return { status: "resolved", identityId }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("TokenTransferScreen", () => { + it("keeps submit disabled with empty fields", () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(idle); + + render( + , + ); + + expect( + (screen.getByRole("button", { name: "Transfer" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it("keeps submit disabled for invalid amount and unresolved DPNS name", () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue({ status: "not-found" }); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "1.5" }, + }); + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: "alice.dash" }, + }); + + expect(screen.getByText("Enter a positive whole amount.")).toBeTruthy(); + expect(screen.getByText(/No identity found/)).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "Transfer" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it("submits a resolved DPNS identity ID", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + mockTransferDashMintTokens.mockResolvedValueOnce(undefined); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "2" }, + }); + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: "Alice.dash" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + await waitFor(() => { + expect(mockTransferDashMintTokens).toHaveBeenCalledWith({ + sdk: sessionValue.sdk, + keyManager: sessionValue.keyManager, + contractId: "contract-1", + recipientId: SAMPLE_ID, + amount: 2n, + availableBalance: 10n, + log: sessionValue.log, + }); + }); + }); + + it("submits trimmed raw identity input through ambiguous fallback", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue("alice"); + mockUseResolvedRecipient.mockReturnValue({ status: "not-found" }); + mockTransferDashMintTokens.mockResolvedValueOnce(undefined); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "3" }, + }); + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: ` ${SAMPLE_ID} ` }, + }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + await waitFor(() => { + expect(mockTransferDashMintTokens).toHaveBeenCalledWith( + expect.objectContaining({ + recipientId: SAMPLE_ID, + amount: 3n, + }), + ); + }); + }); + + it("shows success, clears fields, and notifies the parent", async () => { + const onTransferred = vi.fn(); + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + mockTransferDashMintTokens.mockResolvedValueOnce(undefined); + + render( + , + ); + + const amount = screen.getByLabelText("Amount") as HTMLInputElement; + const recipient = screen.getByLabelText( + "Recipient identity or DPNS name", + ) as HTMLInputElement; + fireEvent.change(amount, { target: { value: "4" } }); + fireEvent.change(recipient, { target: { value: "alice.dash" } }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + await waitFor(() => { + expect(screen.getByRole("status").textContent).toContain( + "DashMint tokens transferred successfully.", + ); + }); + expect(amount.value).toBe(""); + expect(recipient.value).toBe(""); + expect(onTransferred).toHaveBeenCalledTimes(1); + }); + + it("shows inline errors and preserves form values", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + mockTransferDashMintTokens.mockRejectedValueOnce( + new Error("Transfer failed"), + ); + + render( + , + ); + + const amount = screen.getByLabelText("Amount") as HTMLInputElement; + const recipient = screen.getByLabelText( + "Recipient identity or DPNS name", + ) as HTMLInputElement; + fireEvent.change(amount, { target: { value: "5" } }); + fireEvent.change(recipient, { target: { value: "alice.dash" } }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Transfer failed"); + expect(amount.value).toBe("5"); + expect(recipient.value).toBe("alice.dash"); + }); +}); diff --git a/example-apps/dashmint-lab/test/dash.test.ts b/example-apps/dashmint-lab/test/dash.test.ts index ac104e2..8609f36 100644 --- a/example-apps/dashmint-lab/test/dash.test.ts +++ b/example-apps/dashmint-lab/test/dash.test.ts @@ -102,6 +102,9 @@ describe("dashmint helpers", () => { signer: { id: "signer-1" }, }; }, + async getTransfer() { + throw new Error("getTransfer should not be used here"); + }, }; const sdk = { documents: { @@ -145,6 +148,9 @@ describe("dashmint helpers", () => { signer: { id: "signer-1" }, }; }, + async getTransfer() { + throw new Error("getTransfer should not be used here"); + }, }; const sdk = { documents: { @@ -179,6 +185,9 @@ describe("dashmint helpers", () => { signer: { id: "signer-1" }, }; }, + async getTransfer() { + throw new Error("getTransfer should not be used here"); + }, }; const sdk = { documents: { @@ -213,6 +222,9 @@ describe("dashmint helpers", () => { signer: { id: "signer-1" }, }; }, + async getTransfer() { + throw new Error("getTransfer should not be used here"); + }, }; const sdk = { documents: { diff --git a/example-apps/dashmint-lab/test/transferDashMintTokens.test.ts b/example-apps/dashmint-lab/test/transferDashMintTokens.test.ts new file mode 100644 index 0000000..ea6d6e1 --- /dev/null +++ b/example-apps/dashmint-lab/test/transferDashMintTokens.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; + +import { transferDashMintTokens } from "../src/dash/transferDashMintTokens"; +import type { DashKeyManager, DashSdk } from "../src/dash/types"; + +function makeKeyManager(senderId = "sender-1") { + return { + async getAuth() { + throw new Error("getAuth should not be used for token transfers"); + }, + async getTransfer() { + return { + identity: { id: { toString: () => senderId } }, + identityKey: { id: "transfer-key" }, + signer: { id: "transfer-signer" }, + }; + }, + } as unknown as DashKeyManager; +} + +function makeSdk() { + return { + tokens: { + transfer: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as DashSdk; +} + +describe("transferDashMintTokens", () => { + it("uses the transfer key and submits the DashMint token transfer", async () => { + const sdk = makeSdk(); + const keyManager = makeKeyManager("sender-1"); + const log = vi.fn(); + + await transferDashMintTokens({ + sdk, + keyManager, + contractId: "contract-1", + recipientId: "recipient-1", + amount: 3n, + availableBalance: 5n, + log, + }); + + expect(sdk.tokens.transfer).toHaveBeenCalledWith({ + dataContractId: "contract-1", + tokenPosition: 0, + amount: 3n, + senderId: "sender-1", + recipientId: "recipient-1", + identityKey: { id: "transfer-key" }, + signer: { id: "transfer-signer" }, + }); + expect(log).toHaveBeenCalledWith("DashMint tokens transferred.", "success"); + }); + + it("rejects empty recipients before signing", async () => { + const sdk = makeSdk(); + const keyManager = makeKeyManager(); + + await expect( + transferDashMintTokens({ + sdk, + keyManager, + contractId: "contract-1", + recipientId: " ", + amount: 1n, + }), + ).rejects.toThrow("Recipient identity ID is required."); + expect(sdk.tokens.transfer).not.toHaveBeenCalled(); + }); + + it("rejects non-positive amounts before signing", async () => { + const sdk = makeSdk(); + const keyManager = makeKeyManager(); + + await expect( + transferDashMintTokens({ + sdk, + keyManager, + contractId: "contract-1", + recipientId: "recipient-1", + amount: 0n, + }), + ).rejects.toThrow("Amount must be greater than 0."); + expect(sdk.tokens.transfer).not.toHaveBeenCalled(); + }); + + it("rejects amounts above the known local balance", async () => { + const sdk = makeSdk(); + const keyManager = makeKeyManager(); + + await expect( + transferDashMintTokens({ + sdk, + keyManager, + contractId: "contract-1", + recipientId: "recipient-1", + amount: 6n, + availableBalance: 5n, + }), + ).rejects.toThrow("Not enough DashMint tokens."); + expect(sdk.tokens.transfer).not.toHaveBeenCalled(); + }); + + it("rejects self-transfers after resolving the sender identity", async () => { + const sdk = makeSdk(); + const keyManager = makeKeyManager("sender-1"); + + await expect( + transferDashMintTokens({ + sdk, + keyManager, + contractId: "contract-1", + recipientId: "sender-1", + amount: 1n, + }), + ).rejects.toThrow("Cannot transfer tokens to yourself."); + expect(sdk.tokens.transfer).not.toHaveBeenCalled(); + }); +}); diff --git a/setupDashClient-core.d.mts b/setupDashClient-core.d.mts index 6249b5d..3fd6bc4 100644 --- a/setupDashClient-core.d.mts +++ b/setupDashClient-core.d.mts @@ -105,6 +105,15 @@ interface ConnectedDashClientLike { totalSupply( tokenId: string, ): Promise<{ totalSupply: bigint; tokenId: string } | undefined>; + transfer(args: { + dataContractId: string; + tokenPosition: number; + amount: bigint; + senderId: string; + recipientId: string; + identityKey: IdentityPublicKey | undefined; + signer: IdentitySigner; + }): Promise; }; dpns: { username(identityId: string): Promise; @@ -126,6 +135,11 @@ export declare class IdentityKeyManager { identityKey: IdentityPublicKey | undefined; signer: IdentitySigner; }>; + getTransfer(): Promise<{ + identity: Identity; + identityKey: IdentityPublicKey | undefined; + signer: IdentitySigner; + }>; } export declare function createClient( From ef22c16fcc0d0d914d788961faf2fbbc523668dd Mon Sep 17 00:00:00 2001 From: thephez Date: Wed, 3 Jun 2026 17:12:50 -0400 Subject: [PATCH 2/4] refactor(dashmint-lab): tighten token-transfer flow and broaden tests Drop the stale-balance precheck (UI already gates submit, Platform is authoritative), fast-path self-transfer via keyManager.identityId before resolving the signer, and reset the transfer form across session and contract changes. Tests now exercise the recipient classifier directly, pin amount-format edge cases (1e3, -1, 0x10, 1.5, 0), and add an App-level Tokens-tab navigation suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/TokenTransferScreen.tsx | 10 +- .../src/dash/transferDashMintTokens.ts | 13 +- .../test/AppTokenTransferNavigation.test.tsx | 229 ++++++++++++++++++ .../test/TokenTransferScreen.test.tsx | 173 ++++++++++++- .../test/transferDashMintTokens.test.ts | 37 +-- 5 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx diff --git a/example-apps/dashmint-lab/src/components/TokenTransferScreen.tsx b/example-apps/dashmint-lab/src/components/TokenTransferScreen.tsx index 678c911..7401db9 100644 --- a/example-apps/dashmint-lab/src/components/TokenTransferScreen.tsx +++ b/example-apps/dashmint-lab/src/components/TokenTransferScreen.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { classifyRecipientInput, type RecipientMode, @@ -37,6 +37,13 @@ export function TokenTransferScreen({ const [submitting, setSubmitting] = useState(false); const [result, setResult] = useState(null); + useEffect(() => { + setRecipient(""); + setAmountInput(""); + setResult(null); + setSubmitting(false); + }, [contractId, session.identityId, session.status]); + const trimmedRecipient = recipient.trim(); const recipientMode: RecipientMode = trimmedRecipient ? classifyRecipientInput(trimmedRecipient) @@ -93,7 +100,6 @@ export function TokenTransferScreen({ contractId, recipientId, amount, - availableBalance: dashMintTokenBalance, log: session.log, }); setResult({ diff --git a/example-apps/dashmint-lab/src/dash/transferDashMintTokens.ts b/example-apps/dashmint-lab/src/dash/transferDashMintTokens.ts index 4235e60..fda6ed6 100644 --- a/example-apps/dashmint-lab/src/dash/transferDashMintTokens.ts +++ b/example-apps/dashmint-lab/src/dash/transferDashMintTokens.ts @@ -2,7 +2,8 @@ * Transfer DashMint tokens from the signed-in identity to another identity. * * DashMint lives at token position 0 on the active app contract. Token - * transfers use the identity transfer key, unlike card document operations. + * single-transfer transitions can be signed by a critical auth or transfer + * purpose key; this app keeps explicit token sends on the transfer key. * * SDK method: sdk.tokens.transfer({ dataContractId, tokenPosition, amount, senderId, recipientId, identityKey, signer }) */ @@ -16,7 +17,6 @@ export interface TransferDashMintTokensInput { contractId: string; recipientId: string; amount: bigint; - availableBalance?: bigint | null; log?: Logger; } @@ -26,7 +26,6 @@ export async function transferDashMintTokens({ contractId, recipientId, amount, - availableBalance, log, }: TransferDashMintTokensInput): Promise { const trimmedRecipientId = recipientId.trim(); @@ -36,10 +35,10 @@ export async function transferDashMintTokens({ if (amount <= 0n) { throw new Error("Amount must be greater than 0."); } - if (availableBalance !== null && availableBalance !== undefined) { - if (amount > availableBalance) { - throw new Error(`Not enough ${DASHMINT_TOKEN_NAME} tokens.`); - } + + const knownSenderId = keyManager.identityId?.toString(); + if (knownSenderId && trimmedRecipientId === knownSenderId) { + throw new Error("Cannot transfer tokens to yourself."); } const { identity, identityKey, signer } = await keyManager.getTransfer(); diff --git a/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx b/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx new file mode 100644 index 0000000..e591287 --- /dev/null +++ b/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx @@ -0,0 +1,229 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import App from "../src/App"; +import type { Card } from "../src/dash/queries"; +import type { DashKeyManager, DashSdk } from "../src/dash/types"; +import type { ResolvedRecipient } from "../src/hooks/useResolvedRecipient"; + +const { + mockUseSession, + mockListAllCards, + mockListMyCards, + mockListMarketplaceCards, + mockTransferDashMintTokens, + mockUseDpnsName, + mockUseResolvedRecipient, +} = vi.hoisted(() => ({ + mockUseSession: vi.fn(), + mockListAllCards: vi.fn(), + mockListMyCards: vi.fn(), + mockListMarketplaceCards: vi.fn(), + mockTransferDashMintTokens: vi.fn(), + mockUseDpnsName: vi.fn(), + mockUseResolvedRecipient: vi.fn(), +})); + +vi.mock("../src/session/useSession", () => ({ + useSession: mockUseSession, +})); + +vi.mock("../src/dash/queries", () => ({ + listAllCards: mockListAllCards, + listMyCards: mockListMyCards, + listMarketplaceCards: mockListMarketplaceCards, +})); + +vi.mock("../src/dash/transferDashMintTokens", () => ({ + transferDashMintTokens: mockTransferDashMintTokens, +})); + +vi.mock("../src/hooks/useDpnsName", () => ({ + useDpnsName: mockUseDpnsName, +})); + +vi.mock("../src/hooks/useResolvedRecipient", () => ({ + useResolvedRecipient: mockUseResolvedRecipient, +})); + +vi.mock("sonner", () => ({ + Toaster: () =>
, +})); + +vi.mock("../src/components/AppShell", () => ({ + AppShell: ({ + children, + onLoginOpen, + onTabChange, + }: { + children: React.ReactNode; + onLoginOpen: () => void; + onTabChange: ( + tab: "collection" | "mint" | "tokens" | "how-it-works", + ) => void; + }) => ( +
+ + + + {children} +
+ ), +})); + +vi.mock("../src/components/Tabs", () => ({ + SubTabs: ({ + onChange, + showMy, + }: { + onChange: (tab: "my" | "all" | "marketplace") => void; + showMy: boolean; + }) => ( +
+ {showMy && ( + + )} + + +
+ ), +})); + +vi.mock("../src/components/CollectionToolbar", () => ({ + CollectionToolbar: () => , + RefreshSpinner: () => Refreshing, +})); + +vi.mock("../src/components/CardGrid", () => ({ + CardGrid: ({ cards }: { cards: Card[] }) => ( +
+ {cards.map((card) => card.data.name).join("|")} +
+ ), +})); + +vi.mock("../src/components/LoginModal", () => ({ + LoginModal: ({ open }: { open: boolean }) => ( +
open:{String(open)}
+ ), +})); + +vi.mock("../src/components/TransferModal", () => ({ + TransferModal: () =>
, +})); + +vi.mock("../src/components/SetPriceModal", () => ({ + SetPriceModal: () =>
, +})); + +vi.mock("../src/components/PurchaseModal", () => ({ + PurchaseModal: () =>
, +})); + +vi.mock("../src/components/BurnModal", () => ({ + BurnModal: () =>
, +})); + +vi.mock("../src/components/MintForm", () => ({ + MintForm: () =>
Mint Form
, +})); + +vi.mock("../src/components/HowItWorks", () => ({ + HowItWorks: () =>
How It Works
, +})); + +const sessionValue = { + status: "authenticated" as const, + sdk: {} as DashSdk, + keyManager: {} as DashKeyManager, + identityId: "sender-1", + contractId: "contract-1", + contractOwnerId: null as string | null, + balance: null as bigint | null, + dashMintTokenBalance: 10n, + refreshBalance: vi.fn(), + log: vi.fn(), + browseOnly: vi.fn().mockResolvedValue(undefined), +}; + +const cards: Card[] = [ + { + id: "card-1", + ownerId: "sender-1", + data: { name: "Fire Dragon", attack: 9, defense: 8 }, + }, +]; + +const SAMPLE_ID = "5LmvdJbGAtnk2Z3y5bwa2YcX9hk5GhVePtkT21a2mxAn"; +function resolved(identityId: string): ResolvedRecipient { + return { status: "resolved", identityId }; +} + +beforeEach(() => { + mockUseSession.mockReset(); + mockListAllCards.mockReset(); + mockListMyCards.mockReset(); + mockListMarketplaceCards.mockReset(); + mockTransferDashMintTokens.mockReset(); + mockUseDpnsName.mockReset(); + mockUseResolvedRecipient.mockReset(); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("App token transfer navigation", () => { + it("does not retain a token transfer success after leaving and returning to Tokens", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockListMyCards.mockResolvedValue(cards); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + mockTransferDashMintTokens.mockResolvedValue(undefined); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Tokens Tab" })); + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "2" }, + }); + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: "alice.dash" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + await screen.findByRole("status"); + expect(screen.getByRole("status").textContent).toContain( + "DashMint tokens transferred successfully.", + ); + + fireEvent.click(screen.getByRole("button", { name: "Collection Tab" })); + await waitFor(() => { + expect(screen.queryByRole("status")).toBeNull(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Tokens Tab" })); + expect(screen.queryByRole("status")).toBeNull(); + }); +}); diff --git a/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx b/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx index 192b523..fa75aa9 100644 --- a/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx +++ b/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx @@ -42,8 +42,10 @@ vi.mock("../src/hooks/useResolvedRecipient", () => ({ })); const sessionValue = { + status: "authenticated", sdk: {} as DashSdk, keyManager: {} as DashKeyManager, + identityId: "sender-1", log: vi.fn(), }; @@ -132,7 +134,6 @@ describe("TokenTransferScreen", () => { contractId: "contract-1", recipientId: SAMPLE_ID, amount: 2n, - availableBalance: 10n, log: sessionValue.log, }); }); @@ -140,7 +141,7 @@ describe("TokenTransferScreen", () => { it("submits trimmed raw identity input through ambiguous fallback", async () => { mockUseSession.mockReturnValue(sessionValue); - mockUseDpnsName.mockReturnValue("alice"); + mockUseDpnsName.mockReturnValue(null); mockUseResolvedRecipient.mockReturnValue({ status: "not-found" }); mockTransferDashMintTokens.mockResolvedValueOnce(undefined); @@ -154,7 +155,7 @@ describe("TokenTransferScreen", () => { fireEvent.change(screen.getByLabelText("Amount"), { target: { value: "3" }, }); - fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + fireEvent.change(screen.getByPlaceholderText("alice.dash or identity ID"), { target: { value: ` ${SAMPLE_ID} ` }, }); fireEvent.click(screen.getByRole("button", { name: "Transfer" })); @@ -169,6 +170,122 @@ describe("TokenTransferScreen", () => { }); }); + it("trims whole-token amount input before submitting", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + mockTransferDashMintTokens.mockResolvedValueOnce(undefined); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: " 7 " }, + }); + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: "alice.dash" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + await waitFor(() => { + expect(mockTransferDashMintTokens).toHaveBeenCalledWith( + expect.objectContaining({ amount: 7n }), + ); + }); + }); + + it("rejects non-whole amount formats", () => { + const invalidAmounts = ["1e3", "-1", "0x10", "1.5", "0"]; + + for (const invalidAmount of invalidAmounts) { + cleanup(); + vi.clearAllMocks(); + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: invalidAmount }, + }); + fireEvent.change( + screen.getByLabelText("Recipient identity or DPNS name"), + { + target: { value: "alice.dash" }, + }, + ); + + expect(screen.getByText("Enter a positive whole amount.")).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "Transfer" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(mockTransferDashMintTokens).not.toHaveBeenCalled(); + } + }); + + it("passes trimmed name and ambiguous inputs to the resolver hook", () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(idle); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: " Alice.dash " }, + }); + expect(mockUseResolvedRecipient).toHaveBeenLastCalledWith( + sessionValue.sdk, + "Alice.dash", + ); + + fireEvent.change(screen.getByPlaceholderText("alice.dash or identity ID"), { + target: { value: ` ${SAMPLE_ID} ` }, + }); + expect(mockUseResolvedRecipient).toHaveBeenLastCalledWith( + sessionValue.sdk, + SAMPLE_ID, + ); + }); + + it("does not resolve invalid recipient input", () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(idle); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: "alice@dash" }, + }); + + expect(mockUseResolvedRecipient).toHaveBeenLastCalledWith( + sessionValue.sdk, + null, + ); + expect(mockUseDpnsName).toHaveBeenLastCalledWith(sessionValue.sdk, null); + }); + it("shows success, clears fields, and notifies the parent", async () => { const onTransferred = vi.fn(); mockUseSession.mockReturnValue(sessionValue); @@ -202,6 +319,56 @@ describe("TokenTransferScreen", () => { expect(onTransferred).toHaveBeenCalledTimes(1); }); + it("clears a success notice across logout and login", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + mockTransferDashMintTokens.mockResolvedValueOnce(undefined); + + const view = render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "4" }, + }); + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: "alice.dash" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + await screen.findByRole("status"); + expect(screen.getByRole("status").textContent).toContain( + "DashMint tokens transferred successfully.", + ); + + mockUseSession.mockReturnValue({ + ...sessionValue, + status: "browsing", + identityId: null, + keyManager: null, + }); + view.rerender( + , + ); + expect(screen.queryByRole("status")).toBeNull(); + + mockUseSession.mockReturnValue({ + ...sessionValue, + identityId: "sender-2", + }); + view.rerender( + , + ); + expect(screen.queryByRole("status")).toBeNull(); + }); + it("shows inline errors and preserves form values", async () => { mockUseSession.mockReturnValue(sessionValue); mockUseDpnsName.mockReturnValue(null); diff --git a/example-apps/dashmint-lab/test/transferDashMintTokens.test.ts b/example-apps/dashmint-lab/test/transferDashMintTokens.test.ts index ea6d6e1..dbccc11 100644 --- a/example-apps/dashmint-lab/test/transferDashMintTokens.test.ts +++ b/example-apps/dashmint-lab/test/transferDashMintTokens.test.ts @@ -4,17 +4,21 @@ import { transferDashMintTokens } from "../src/dash/transferDashMintTokens"; import type { DashKeyManager, DashSdk } from "../src/dash/types"; function makeKeyManager(senderId = "sender-1") { + const getTransfer = vi.fn(async () => ({ + identity: { id: { toString: () => senderId } }, + identityKey: { id: "transfer-key" }, + signer: { id: "transfer-signer" }, + })); return { + identityId: senderId, async getAuth() { - throw new Error("getAuth should not be used for token transfers"); - }, - async getTransfer() { return { identity: { id: { toString: () => senderId } }, - identityKey: { id: "transfer-key" }, - signer: { id: "transfer-signer" }, + identityKey: { id: "auth-key" }, + signer: { id: "auth-signer" }, }; }, + getTransfer, } as unknown as DashKeyManager; } @@ -27,7 +31,7 @@ function makeSdk() { } describe("transferDashMintTokens", () => { - it("uses the transfer key and submits the DashMint token transfer", async () => { + it("uses the app's transfer signer and submits the DashMint token transfer", async () => { const sdk = makeSdk(); const keyManager = makeKeyManager("sender-1"); const log = vi.fn(); @@ -38,7 +42,6 @@ describe("transferDashMintTokens", () => { contractId: "contract-1", recipientId: "recipient-1", amount: 3n, - availableBalance: 5n, log, }); @@ -86,26 +89,29 @@ describe("transferDashMintTokens", () => { expect(sdk.tokens.transfer).not.toHaveBeenCalled(); }); - it("rejects amounts above the known local balance", async () => { + it("rejects known self-transfers before resolving the signer", async () => { const sdk = makeSdk(); - const keyManager = makeKeyManager(); + const keyManager = makeKeyManager("sender-1"); await expect( transferDashMintTokens({ sdk, keyManager, contractId: "contract-1", - recipientId: "recipient-1", - amount: 6n, - availableBalance: 5n, + recipientId: "sender-1", + amount: 1n, }), - ).rejects.toThrow("Not enough DashMint tokens."); + ).rejects.toThrow("Cannot transfer tokens to yourself."); + expect(keyManager.getTransfer).not.toHaveBeenCalled(); expect(sdk.tokens.transfer).not.toHaveBeenCalled(); }); - it("rejects self-transfers after resolving the sender identity", async () => { + it("still rejects self-transfers after resolving the sender identity", async () => { const sdk = makeSdk(); - const keyManager = makeKeyManager("sender-1"); + const keyManager = { + ...makeKeyManager("sender-1"), + identityId: null, + } as unknown as DashKeyManager; await expect( transferDashMintTokens({ @@ -116,6 +122,7 @@ describe("transferDashMintTokens", () => { amount: 1n, }), ).rejects.toThrow("Cannot transfer tokens to yourself."); + expect(keyManager.getTransfer).toHaveBeenCalledTimes(1); expect(sdk.tokens.transfer).not.toHaveBeenCalled(); }); }); From 53766712d7f8505f2d7e4ef17aaeb008447a3cd1 Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 9 Jun 2026 11:30:54 -0400 Subject: [PATCH 3/4] test(dashmint-lab): close coverage gaps on token transfer screen Split the bundled disabled-submit test into separate amount and DPNS cases, rewrite the session-reset test to seed stale state via a rejected transfer so it actually exercises the reset effect, and rename two tests whose names overpromised their assertions. Add coverage for insufficient local balance, missing key manager, dismiss-on-edit, and the Clear button. Switch the invalid-amount table to it.each for per-case failure messages, and drop a redundant clearAllMocks in the nav test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test/AppTokenTransferNavigation.test.tsx | 1 - .../test/TokenTransferScreen.test.tsx | 195 ++++++++++++++++-- 2 files changed, 176 insertions(+), 20 deletions(-) diff --git a/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx b/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx index e591287..9c1b0b6 100644 --- a/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx +++ b/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx @@ -191,7 +191,6 @@ beforeEach(() => { afterEach(() => { cleanup(); - vi.clearAllMocks(); }); describe("App token transfer navigation", () => { diff --git a/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx b/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx index fa75aa9..85e5898 100644 --- a/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx +++ b/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx @@ -79,10 +79,10 @@ describe("TokenTransferScreen", () => { ).toBe(true); }); - it("keeps submit disabled for invalid amount and unresolved DPNS name", () => { + it("keeps submit disabled for an invalid amount", () => { mockUseSession.mockReturnValue(sessionValue); mockUseDpnsName.mockReturnValue(null); - mockUseResolvedRecipient.mockReturnValue({ status: "not-found" }); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); render( { }); expect(screen.getByText("Enter a positive whole amount.")).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "Transfer" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it("keeps submit disabled for an unresolved DPNS name", () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue({ status: "not-found" }); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "2" }, + }); + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: "alice.dash" }, + }); + expect(screen.getByText(/No identity found/)).toBeTruthy(); expect( (screen.getByRole("button", { name: "Transfer" }) as HTMLButtonElement) @@ -106,7 +131,60 @@ describe("TokenTransferScreen", () => { ).toBe(true); }); - it("submits a resolved DPNS identity ID", async () => { + it("keeps submit disabled for an amount above the local balance", () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "5" }, + }); + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: "alice.dash" }, + }); + + expect(screen.getByText("Insufficient DashMint balance.")).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "Transfer" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(mockTransferDashMintTokens).not.toHaveBeenCalled(); + }); + + it("keeps submit disabled without a key manager", () => { + mockUseSession.mockReturnValue({ + ...sessionValue, + keyManager: null, + }); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "2" }, + }); + fireEvent.change(screen.getByLabelText("Recipient identity or DPNS name"), { + target: { value: "alice.dash" }, + }); + + expect( + (screen.getByRole("button", { name: "Transfer" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(mockTransferDashMintTokens).not.toHaveBeenCalled(); + }); + + it("submits the resolved recipient identity ID", async () => { mockUseSession.mockReturnValue(sessionValue); mockUseDpnsName.mockReturnValue(null); mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); @@ -139,7 +217,7 @@ describe("TokenTransferScreen", () => { }); }); - it("submits trimmed raw identity input through ambiguous fallback", async () => { + it("submits trimmed raw identity input when DPNS resolution does not match", async () => { mockUseSession.mockReturnValue(sessionValue); mockUseDpnsName.mockReturnValue(null); mockUseResolvedRecipient.mockReturnValue({ status: "not-found" }); @@ -198,12 +276,9 @@ describe("TokenTransferScreen", () => { }); }); - it("rejects non-whole amount formats", () => { - const invalidAmounts = ["1e3", "-1", "0x10", "1.5", "0"]; - - for (const invalidAmount of invalidAmounts) { - cleanup(); - vi.clearAllMocks(); + it.each(["1e3", "-1", "0x10", "1.5", "0"])( + "rejects non-whole amount format %s", + (invalidAmount) => { mockUseSession.mockReturnValue(sessionValue); mockUseDpnsName.mockReturnValue(null); mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); @@ -231,8 +306,8 @@ describe("TokenTransferScreen", () => { .disabled, ).toBe(true); expect(mockTransferDashMintTokens).not.toHaveBeenCalled(); - } - }); + }, + ); it("passes trimmed name and ambiguous inputs to the resolver hook", () => { mockUseSession.mockReturnValue(sessionValue); @@ -319,11 +394,13 @@ describe("TokenTransferScreen", () => { expect(onTransferred).toHaveBeenCalledTimes(1); }); - it("clears a success notice across logout and login", async () => { + it("resets form values and notice when the session changes", async () => { mockUseSession.mockReturnValue(sessionValue); mockUseDpnsName.mockReturnValue(null); mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); - mockTransferDashMintTokens.mockResolvedValueOnce(undefined); + mockTransferDashMintTokens.mockRejectedValueOnce( + new Error("Transfer failed"), + ); const view = render( { }); fireEvent.click(screen.getByRole("button", { name: "Transfer" })); - await screen.findByRole("status"); - expect(screen.getByRole("status").textContent).toContain( - "DashMint tokens transferred successfully.", + await screen.findByRole("alert"); + expect(screen.getByRole("alert").textContent).toContain("Transfer failed"); + expect((screen.getByLabelText("Amount") as HTMLInputElement).value).toBe( + "4", ); + expect( + ( + screen.getByPlaceholderText( + "alice.dash or identity ID", + ) as HTMLInputElement + ).value, + ).toBe("alice.dash"); mockUseSession.mockReturnValue({ ...sessionValue, @@ -357,7 +442,17 @@ describe("TokenTransferScreen", () => { dashMintTokenBalance={null} />, ); - expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + expect((screen.getByLabelText("Amount") as HTMLInputElement).value).toBe( + "", + ); + expect( + ( + screen.getByPlaceholderText( + "alice.dash or identity ID", + ) as HTMLInputElement + ).value, + ).toBe(""); mockUseSession.mockReturnValue({ ...sessionValue, @@ -366,7 +461,7 @@ describe("TokenTransferScreen", () => { view.rerender( , ); - expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); }); it("shows inline errors and preserves form values", async () => { @@ -397,4 +492,66 @@ describe("TokenTransferScreen", () => { expect(amount.value).toBe("5"); expect(recipient.value).toBe("alice.dash"); }); + + it("clears an existing notice when editing the form", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + mockTransferDashMintTokens.mockRejectedValueOnce( + new Error("Transfer failed"), + ); + + render( + , + ); + + const amount = screen.getByLabelText("Amount") as HTMLInputElement; + const recipient = screen.getByLabelText( + "Recipient identity or DPNS name", + ) as HTMLInputElement; + fireEvent.change(amount, { target: { value: "5" } }); + fireEvent.change(recipient, { target: { value: "alice.dash" } }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + await screen.findByRole("alert"); + fireEvent.change(amount, { target: { value: "6" } }); + + expect(screen.queryByRole("alert")).toBeNull(); + expect(amount.value).toBe("6"); + expect(recipient.value).toBe("alice.dash"); + }); + + it("clears form values and notice when Clear is clicked", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + mockTransferDashMintTokens.mockRejectedValueOnce( + new Error("Transfer failed"), + ); + + render( + , + ); + + const amount = screen.getByLabelText("Amount") as HTMLInputElement; + const recipient = screen.getByLabelText( + "Recipient identity or DPNS name", + ) as HTMLInputElement; + fireEvent.change(amount, { target: { value: "5" } }); + fireEvent.change(recipient, { target: { value: "alice.dash" } }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + await screen.findByRole("alert"); + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + + expect(screen.queryByRole("alert")).toBeNull(); + expect(amount.value).toBe(""); + expect(recipient.value).toBe(""); + }); }); From f7c5de940a444826955a00f2a29d7800ae6b17b0 Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 9 Jun 2026 11:58:03 -0400 Subject: [PATCH 4/4] test(dashmint-lab): assert transfer args in App nav test The token-transfer navigation test confirmed the success notice appeared after submit but never verified what was actually sent. Pin contractId, recipientId, and amount on the mock invocation so a regression in App-to-screen wiring (wrong contract, dropped recipient) can't slip past as long as the notice still renders. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashmint-lab/test/AppTokenTransferNavigation.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx b/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx index 9c1b0b6..0c461a6 100644 --- a/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx +++ b/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx @@ -216,6 +216,13 @@ describe("App token transfer navigation", () => { expect(screen.getByRole("status").textContent).toContain( "DashMint tokens transferred successfully.", ); + expect(mockTransferDashMintTokens).toHaveBeenCalledWith( + expect.objectContaining({ + contractId: "contract-1", + recipientId: SAMPLE_ID, + amount: 2n, + }), + ); fireEvent.click(screen.getByRole("button", { name: "Collection Tab" })); await waitFor(() => {