diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 451c93a..90ea79e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,35 @@ Thanks for helping improve Stellar RWA Web. - The project currently targets Stellar Testnet. Mainnet contract IDs are intentionally empty in the example env file. - Install dependencies with `npm install` and start the app with `npm run dev`. +## Issuer dashboard: on-chain roles and failure behaviour + +The three tabs in the issuer dashboard call privileged contract methods. Each +requires the connected wallet to be the asset's on-chain admin (the address +stored in `AssetMetadata.admin`, set at token deployment). All three tabs share +the same requirement — there is currently no finer-grained role separation +between them. + +| Tab | Methods called | Required role | +|---|---|---| +| Token | `mint`, `pause`, `unpause` | asset-token `admin` | +| Compliance | `add_to_allowlist`, `suspend`, `remove`, `block_jurisdiction`, `unblock_jurisdiction` | compliance contract `admin` (set to the same address as the asset-token admin at deployment) | +| Distributions | `create_distribution` | dividend contract caller must be the asset-token address's registered issuer; in practice this is the same wallet that deployed the token | + +### What happens when a non-admin wallet submits + +The Soroban contract enforces the admin check and reverts the transaction with +an `Auth` error. `useTx` catches the revert, maps it to a human-readable +message, and surfaces it via the `TxProgress` component as a generic +"Transaction failed" toast. The UI does not currently distinguish an +authorisation failure from any other contract error — the issuer dashboard +assumes the connected wallet *is* the admin, and no warning is shown upfront if +it is not. + +Planned improvement: compare `AssetMetadata.admin` against `useWallet().address` +on the client side before enabling the action buttons, and show a clear +"You are not the admin of this asset" notice instead of letting the transaction +fail on-chain. + ## Verification Run these commands before submitting: diff --git a/README.md b/README.md index 974829e..1e9154d 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,26 @@ types/ Domain types mirroring the contracts - Monetary valuations are stored on-chain as **USD cents** (`i128`); token amounts are integers in each token's own `decimals` base. +### Issuer dashboard roles + +The `/issuer` dashboard exposes three tabs that call privileged contract +methods. All three require the connected wallet to match the asset's on-chain +`admin` address (recorded in `AssetMetadata.admin` at token deployment). + +| Tab | Contract methods | Required on-chain role | +|---|---|---| +| Token | `mint`, `pause`, `unpause` | asset-token `admin` | +| Compliance | `add_to_allowlist`, `suspend`, `remove`, `block_jurisdiction`, `unblock_jurisdiction` | compliance contract `admin` (same address as the token admin) | +| Distributions | `create_distribution` | asset-token `admin` / registered issuer | + +When a non-admin wallet submits any of these actions, the Soroban contract +reverts with an `Auth` error. `useTx` catches the revert and surfaces it as a +generic "Transaction failed" toast via `TxProgress`. The UI does not currently +distinguish an authorisation failure from other contract errors — it assumes the +connected wallet is the admin. A future improvement is to compare +`AssetMetadata.admin` against the connected address in the client and show an +explicit "you are not the admin" notice before letting an action be attempted. + ## Pages | Route | Status | Description | diff --git a/components/issuer/panels/CompliancePanel.tsx b/components/issuer/panels/CompliancePanel.tsx index 64f6897..1e28df4 100644 --- a/components/issuer/panels/CompliancePanel.tsx +++ b/components/issuer/panels/CompliancePanel.tsx @@ -320,8 +320,13 @@ function JurisdictionCard({ return null; } + // Either form being in-flight locks out the other to prevent racing a + // block and an unblock against the same jurisdiction before either confirms. + const eitherPending = blockTx.pending || unblockTx.pending; + async function onBlock(e: React.FormEvent) { e.preventDefault(); + if (eitherPending) return; const err = validateJur(blockJur); if (err) { setBlockError(err); return; } setBlockError(null); @@ -333,6 +338,7 @@ function JurisdictionCard({ async function onUnblock(e: React.FormEvent) { e.preventDefault(); + if (eitherPending) return; const err = validateJur(unblockJur); if (err) { setUnblockError(err); return; } setUnblockError(null); @@ -365,10 +371,10 @@ function JurisdictionCard({ onChange={(e) => setBlockJur(e.target.value.toUpperCase())} placeholder="e.g. KP" maxLength={3} - disabled={blockTx.pending} + disabled={eitherPending} className="input flex-1 uppercase" /> - @@ -394,10 +400,10 @@ function JurisdictionCard({ onChange={(e) => setUnblockJur(e.target.value.toUpperCase())} placeholder="e.g. US" maxLength={3} - disabled={unblockTx.pending} + disabled={eitherPending} className="input flex-1 uppercase" /> - diff --git a/components/issuer/panels/DistributionPanel.tsx b/components/issuer/panels/DistributionPanel.tsx index 4d3f2cf..4c91aa2 100644 --- a/components/issuer/panels/DistributionPanel.tsx +++ b/components/issuer/panels/DistributionPanel.tsx @@ -214,7 +214,7 @@ function ExistingDistributionsCard({ tokenContract }: { tokenContract: string })
diff --git a/components/issuer/panels/__tests__/DistributionPanel.test.tsx b/components/issuer/panels/__tests__/DistributionPanel.test.tsx new file mode 100644 index 0000000..1b7f9c5 --- /dev/null +++ b/components/issuer/panels/__tests__/DistributionPanel.test.tsx @@ -0,0 +1,234 @@ +/** + * Tests for components/issuer/panels/DistributionPanel.tsx + * + * Focus: ExistingDistributionsCard renders a progress bar whose width is + * derived from percent(d.distributed, d.totalAmount). The width must always be + * clamped to [0, 100] — even if percent() or upstream data produces a value + * outside that range due to rounding or a stale snapshot. + * + * Strategy: mock useDividends so we control the Distribution objects directly, + * mock useTx / CreateDistributionCard dependencies so we only test the + * ExistingDistributionsCard branch, and spy on percent() to confirm the clamp + * at the render site defends against an out-of-range return. + */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import type { AssetDetail } from "@/types"; + +// ── mock useDividends ────────────────────────────────────────────────────── + +jest.mock("@/hooks/useDividends", () => ({ + useDividends: jest.fn(), +})); + +// ── mock useTx (CreateDistributionCard) ─────────────────────────────────── + +jest.mock("@/hooks/useTx", () => ({ + useTx: jest.fn(() => ({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: jest.fn(), + reset: jest.fn(), + })), +})); + +// ── mock @stellar/stellar-sdk ───────────────────────────────────────────── + +jest.mock("@stellar/stellar-sdk", () => ({ + StrKey: { + isValidEd25519PublicKey: () => false, + isValidContract: () => false, + }, +})); + +// ── mock ActionCard / TxProgress / Spinner / EmptyState / ErrorState ────── + +jest.mock("@/components/issuer/ActionCard", () => ({ + ActionCard: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +jest.mock("@/components/ui/TxProgress", () => ({ + TxProgress: () =>
, +})); +jest.mock("@/components/ui/Spinner", () => ({ + Spinner: () => , +})); +jest.mock("@/components/ui/EmptyState", () => ({ + EmptyState: ({ title }: { title: string }) =>
{title}
, +})); +jest.mock("@/components/ui/ErrorState", () => ({ + ErrorState: ({ title }: { title: string }) =>
{title}
, +})); + +// ── mock ClaimButton constant ───────────────────────────────────────────── + +jest.mock("@/components/dividend/ClaimButton", () => ({ + PAYMENT_TOKEN_DECIMALS: 7, +})); + +// ── mock percent so we can force out-of-range values ────────────────────── +// By default we proxy to the real implementation; individual tests override. + +import * as formatModule from "@/lib/format"; +const realPercent = formatModule.percent; +const percentSpy = jest.spyOn(formatModule, "percent"); + +// ── imports after mocks ──────────────────────────────────────────────────── + +import { useDividends } from "@/hooks/useDividends"; +import { DistributionPanel } from "../DistributionPanel"; + +const mockUseDividends = useDividends as jest.MockedFunction; + +// ── helpers ──────────────────────────────────────────────────────────────── + +function makeAsset(): AssetDetail { + return { + id: 1n, + tokenContract: "CTOKEN123", + issuer: "GISSUER123", + name: "Test Asset", + assetType: "real_estate", + valuation: 1_000_000_00n, + createdAt: 50000, + active: true, + metadata: { + name: "Test Asset", + symbol: "TST", + assetType: "real_estate", + totalSupply: 1_000_000n, + decimals: 7, + admin: "GISSUER123", + complianceContract: "CCOMPLIANCE", + assetDescription: "", + valuation: 1_000_000_00n, + paused: false, + }, + }; +} + +type DistributionItem = ReturnType["data"] extends Array | null + ? T + : never; + +function makeDistribution( + id: bigint, + distributed: bigint, + totalAmount: bigint, + completed = false, +): DistributionItem { + return { + id, + assetToken: "CTOKEN123", + paymentToken: "CPAYTOKEN", + totalAmount, + distributed, + createdAt: 100, + completed, + claimable: 0n, + claimed: false, + }; +} + +type DividendsReturn = ReturnType; + +function setupDividends(data: DividendsReturn["data"], extras: Partial = {}) { + mockUseDividends.mockReturnValue({ + data, + loading: false, + error: null, + refetch: jest.fn(), + ...extras, + } as DividendsReturn); +} + +// ── tests ────────────────────────────────────────────────────────────────── + +describe("DistributionPanel – ExistingDistributionsCard progress bar clamping", () => { + beforeEach(() => { + percentSpy.mockImplementation(realPercent); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders a progress bar at 50% when half the total is distributed", () => { + setupDividends([makeDistribution(1n, 5_000_000n, 10_000_000n)]); + render(); + + const bar = document.querySelector(".bg-gradient-to-r") as HTMLElement; + expect(bar).toBeTruthy(); + expect(bar.style.width).toBe("50%"); + }); + + it("renders the bar at 100% when fully distributed (normal complete case)", () => { + setupDividends([makeDistribution(1n, 10_000_000n, 10_000_000n, true)]); + render(); + + const bar = document.querySelector(".bg-gradient-to-r") as HTMLElement; + expect(bar.style.width).toBe("100%"); + }); + + it("clamps bar width to 100% when percent() returns above 100", () => { + // Force percent() to return 102.5 to simulate a rounding path where + // distributed slightly exceeds totalAmount. + percentSpy.mockReturnValue(102.5); + + setupDividends([makeDistribution(1n, 10_200_000n, 10_000_000n)]); + render(); + + const bar = document.querySelector(".bg-gradient-to-r") as HTMLElement; + expect(bar.style.width).toBe("100%"); + }); + + it("clamps bar width to 0% when percent() returns a negative value", () => { + // Defensive: percent() itself clamps but we guard at the render site too. + percentSpy.mockReturnValue(-5); + + setupDividends([makeDistribution(1n, 0n, 10_000_000n)]); + render(); + + const bar = document.querySelector(".bg-gradient-to-r") as HTMLElement; + expect(bar.style.width).toBe("0%"); + }); + + it("renders a bar at 0% for a brand-new distribution with nothing distributed", () => { + setupDividends([makeDistribution(1n, 0n, 10_000_000n)]); + render(); + + const bar = document.querySelector(".bg-gradient-to-r") as HTMLElement; + expect(bar.style.width).toBe("0%"); + }); + + it("renders multiple distributions with individually correct bar widths", () => { + setupDividends([ + makeDistribution(1n, 2_500_000n, 10_000_000n), // 25% + makeDistribution(2n, 10_000_000n, 10_000_000n, true), // 100% + ]); + render(); + + const bars = Array.from( + document.querySelectorAll(".bg-gradient-to-r"), + ); + expect(bars).toHaveLength(2); + expect(bars[0].style.width).toBe("25%"); + expect(bars[1].style.width).toBe("100%"); + }); + + it("shows an empty state when there are no distributions", () => { + setupDividends([]); + render(); + + expect(screen.getByText(/no distributions yet/i)).toBeInTheDocument(); + }); + + it("shows an error state with retry when the distributions fetch fails", () => { + setupDividends(null, { error: "RPC unavailable", loading: false }); + render(); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); +}); diff --git a/components/issuer/panels/__tests__/TokenPanel.test.tsx b/components/issuer/panels/__tests__/TokenPanel.test.tsx new file mode 100644 index 0000000..c163a24 --- /dev/null +++ b/components/issuer/panels/__tests__/TokenPanel.test.tsx @@ -0,0 +1,228 @@ +/** + * Tests for components/issuer/panels/TokenPanel.tsx + * + * Focus: how MintCard surfaces parseTokenAmount validation errors as form + * validation messages — specifically, amounts with more fractional digits than + * metadata.decimals allows, and a token with decimals === 0. + * + * Strategy: mock useTx so no Soroban/Freighter calls occur, mock StrKey so + * address validation is trivially satisfied, and submit the form directly via + * userEvent / fireEvent. + */ + +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import type { AssetDetail } from "@/types"; + +// ── mock useTx ───────────────────────────────────────────────────────────── +// Default: idle, never pending — the submit button is visible. + +const mockRun = jest.fn(); + +jest.mock("@/hooks/useTx", () => ({ + useTx: jest.fn(() => ({ + phase: "idle", + hash: null, + error: null, + pending: false, + run: mockRun, + reset: jest.fn(), + })), +})); + +// ── mock @stellar/stellar-sdk (StrKey) ───────────────────────────────────── +// Accept any string starting with "G" as a valid Ed25519 public key so tests +// don't have to generate real Stellar keypairs. + +jest.mock("@stellar/stellar-sdk", () => ({ + StrKey: { + isValidEd25519PublicKey: (addr: string) => addr.startsWith("G"), + isValidContract: (addr: string) => addr.startsWith("C"), + }, +})); + +// ── mock ActionCard / TxProgress (layout-only) ──────────────────────────── + +jest.mock("@/components/issuer/ActionCard", () => ({ + ActionCard: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock("@/components/ui/TxProgress", () => ({ + TxProgress: () =>
, +})); + +// ── imports after mocks ──────────────────────────────────────────────────── + +import { TokenPanel } from "../TokenPanel"; + +// ── helpers ──────────────────────────────────────────────────────────────── + +function makeAsset(decimals: number, paused = false): AssetDetail { + return { + id: 1n, + tokenContract: "CTOKEN123", + issuer: "GISSUER123", + name: "Test Asset", + assetType: "real_estate", + valuation: 1_000_000_00n, + createdAt: 50000, + active: true, + metadata: { + name: "Test Asset", + symbol: "TST", + assetType: "real_estate", + totalSupply: 1_000_000n, + decimals, + admin: "GISSUER123", + complianceContract: "CCOMPLIANCE", + assetDescription: "A test asset", + valuation: 1_000_000_00n, + paused, + }, + }; +} + +const VALID_RECIPIENT = "GABCDEFGHIJ"; + +async function fillAndSubmit(amount: string, recipient = VALID_RECIPIENT) { + fireEvent.change(screen.getByLabelText(/recipient address/i), { + target: { value: recipient }, + }); + fireEvent.change(screen.getByLabelText(/amount/i), { + target: { value: amount }, + }); + fireEvent.click(screen.getByRole("button", { name: /mint/i })); +} + +// ── tests ────────────────────────────────────────────────────────────────── + +describe("TokenPanel – MintCard parseTokenAmount validation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── decimals = 7 (normal Stellar token) ─────────────────────────────── + + describe("token with 7 decimals", () => { + beforeEach(() => { + render(); + }); + + it("accepts an amount with exactly 7 decimal places and calls tx.run", async () => { + await fillAndSubmit("1.1234567"); + await waitFor(() => { + expect(screen.queryByRole("paragraph")).not.toHaveTextContent(/decimal/i); + expect(mockRun).toHaveBeenCalledTimes(1); + }); + }); + + it("shows an error and does not call tx.run when amount has 8 decimal places", async () => { + await fillAndSubmit("1.12345678"); + await waitFor(() => { + expect(screen.getByText(/maximum 7 decimal places/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); + + it("shows an error for 1 extra decimal digit beyond the token's precision", async () => { + await fillAndSubmit("0.00000001"); // 8 fractional digits + await waitFor(() => { + expect(screen.getByText(/maximum 7 decimal places/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); + + it("shows 'Enter a valid number' for a non-numeric string", async () => { + await fillAndSubmit("abc"); + await waitFor(() => { + expect(screen.getByText(/enter a valid number/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); + + it("shows 'Enter a valid number' for scientific notation", async () => { + await fillAndSubmit("1e5"); + await waitFor(() => { + expect(screen.getByText(/enter a valid number/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); + + it("shows 'Amount must be greater than zero' for 0", async () => { + await fillAndSubmit("0"); + await waitFor(() => { + expect(screen.getByText(/amount must be greater than zero/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); + }); + + // ── decimals = 0 (whole-unit-only token) ────────────────────────────── + + describe("token with 0 decimals", () => { + beforeEach(() => { + render(); + }); + + it("accepts a whole number and calls tx.run", async () => { + await fillAndSubmit("100"); + await waitFor(() => { + expect(mockRun).toHaveBeenCalledTimes(1); + }); + }); + + it("shows 'Maximum 0 decimal places' for any fractional input", async () => { + await fillAndSubmit("1.5"); + await waitFor(() => { + expect(screen.getByText(/maximum 0 decimal places/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); + + it("shows the error even for a single trailing decimal digit", async () => { + await fillAndSubmit("10.1"); + await waitFor(() => { + expect(screen.getByText(/maximum 0 decimal places/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); + + it("shows 'Enter a valid number' for an empty string", async () => { + await fillAndSubmit(""); + await waitFor(() => { + expect(screen.getByText(/enter a valid number/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); + }); + + // ── decimals = 2 (edge case) ─────────────────────────────────────────── + + describe("token with 2 decimals", () => { + beforeEach(() => { + render(); + }); + + it("shows 'Maximum 2 decimal places' when 3 fractional digits are entered", async () => { + await fillAndSubmit("1.123"); + await waitFor(() => { + expect(screen.getByText(/maximum 2 decimal places/i)).toBeInTheDocument(); + expect(mockRun).not.toHaveBeenCalled(); + }); + }); + + it("accepts exactly 2 decimal places", async () => { + await fillAndSubmit("1.99"); + await waitFor(() => { + expect(mockRun).toHaveBeenCalledTimes(1); + }); + }); + + it("accepts a value with no fractional part", async () => { + await fillAndSubmit("1000"); + await waitFor(() => { + expect(mockRun).toHaveBeenCalledTimes(1); + }); + }); + }); +});