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); + + useEffect(() => { + setRecipient(""); + setAmountInput(""); + setResult(null); + setSubmitting(false); + }, [contractId, session.identityId, session.status]); + + 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, + 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..fda6ed6 --- /dev/null +++ b/example-apps/dashmint-lab/src/dash/transferDashMintTokens.ts @@ -0,0 +1,67 @@ +/** + * Transfer DashMint tokens from the signed-in identity to another identity. + * + * DashMint lives at token position 0 on the active app contract. Token + * 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 }) + */ +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; + log?: Logger; +} + +export async function transferDashMintTokens({ + sdk, + keyManager, + contractId, + recipientId, + amount, + 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."); + } + + const knownSenderId = keyManager.identityId?.toString(); + if (knownSenderId && trimmedRecipientId === knownSenderId) { + throw new Error("Cannot transfer tokens to yourself."); + } + + 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/AppTokenTransferNavigation.test.tsx b/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx new file mode 100644 index 0000000..0c461a6 --- /dev/null +++ b/example-apps/dashmint-lab/test/AppTokenTransferNavigation.test.tsx @@ -0,0 +1,235 @@ +// @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(); +}); + +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.", + ); + expect(mockTransferDashMintTokens).toHaveBeenCalledWith( + expect.objectContaining({ + contractId: "contract-1", + recipientId: SAMPLE_ID, + amount: 2n, + }), + ); + + 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 new file mode 100644 index 0000000..85e5898 --- /dev/null +++ b/example-apps/dashmint-lab/test/TokenTransferScreen.test.tsx @@ -0,0 +1,557 @@ +// @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 = { + status: "authenticated", + sdk: {} as DashSdk, + keyManager: {} as DashKeyManager, + identityId: "sender-1", + 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 an invalid amount", () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + + 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.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) + .disabled, + ).toBe(true); + }); + + 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)); + 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, + log: sessionValue.log, + }); + }); + }); + + it("submits trimmed raw identity input when DPNS resolution does not match", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue({ status: "not-found" }); + mockTransferDashMintTokens.mockResolvedValueOnce(undefined); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Amount"), { + target: { value: "3" }, + }); + fireEvent.change(screen.getByPlaceholderText("alice.dash or identity ID"), { + target: { value: ` ${SAMPLE_ID} ` }, + }); + fireEvent.click(screen.getByRole("button", { name: "Transfer" })); + + await waitFor(() => { + expect(mockTransferDashMintTokens).toHaveBeenCalledWith( + expect.objectContaining({ + recipientId: SAMPLE_ID, + amount: 3n, + }), + ); + }); + }); + + 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.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)); + + 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); + 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("resets form values and notice when the session changes", async () => { + mockUseSession.mockReturnValue(sessionValue); + mockUseDpnsName.mockReturnValue(null); + mockUseResolvedRecipient.mockReturnValue(resolved(SAMPLE_ID)); + mockTransferDashMintTokens.mockRejectedValueOnce( + new Error("Transfer failed"), + ); + + 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("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, + status: "browsing", + identityId: null, + keyManager: null, + }); + view.rerender( + , + ); + 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, + identityId: "sender-2", + }); + view.rerender( + , + ); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + 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"); + }); + + 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(""); + }); +}); 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..dbccc11 --- /dev/null +++ b/example-apps/dashmint-lab/test/transferDashMintTokens.test.ts @@ -0,0 +1,128 @@ +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") { + const getTransfer = vi.fn(async () => ({ + identity: { id: { toString: () => senderId } }, + identityKey: { id: "transfer-key" }, + signer: { id: "transfer-signer" }, + })); + return { + identityId: senderId, + async getAuth() { + return { + identity: { id: { toString: () => senderId } }, + identityKey: { id: "auth-key" }, + signer: { id: "auth-signer" }, + }; + }, + getTransfer, + } as unknown as DashKeyManager; +} + +function makeSdk() { + return { + tokens: { + transfer: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as DashSdk; +} + +describe("transferDashMintTokens", () => { + 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(); + + await transferDashMintTokens({ + sdk, + keyManager, + contractId: "contract-1", + recipientId: "recipient-1", + amount: 3n, + 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 known self-transfers before resolving the signer", 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(keyManager.getTransfer).not.toHaveBeenCalled(); + expect(sdk.tokens.transfer).not.toHaveBeenCalled(); + }); + + it("still rejects self-transfers after resolving the sender identity", async () => { + const sdk = makeSdk(); + const keyManager = { + ...makeKeyManager("sender-1"), + identityId: null, + } as unknown as DashKeyManager; + + await expect( + transferDashMintTokens({ + sdk, + keyManager, + contractId: "contract-1", + recipientId: "sender-1", + amount: 1n, + }), + ).rejects.toThrow("Cannot transfer tokens to yourself."); + expect(keyManager.getTransfer).toHaveBeenCalledTimes(1); + 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(