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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 10 additions & 4 deletions components/issuer/panels/CompliancePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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"
/>
<button type="submit" disabled={blockTx.pending} className="btn-secondary shrink-0">
<button type="submit" disabled={eitherPending} className="btn-secondary shrink-0">
Block
</button>
</div>
Expand All @@ -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"
/>
<button type="submit" disabled={unblockTx.pending} className="btn-secondary shrink-0">
<button type="submit" disabled={eitherPending} className="btn-secondary shrink-0">
Unblock
</button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion components/issuer/panels/DistributionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ function ExistingDistributionsCard({ tokenContract }: { tokenContract: string })
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-gradient-to-r from-brand-500 to-brand-400"
style={{ width: `${pct}%` }}
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
/>
</div>
</li>
Expand Down
234 changes: 234 additions & 0 deletions components/issuer/panels/__tests__/DistributionPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div>{children}</div>,
}));
jest.mock("@/components/ui/TxProgress", () => ({
TxProgress: () => <div data-testid="tx-progress" />,
}));
jest.mock("@/components/ui/Spinner", () => ({
Spinner: () => <span data-testid="spinner" />,
}));
jest.mock("@/components/ui/EmptyState", () => ({
EmptyState: ({ title }: { title: string }) => <div>{title}</div>,
}));
jest.mock("@/components/ui/ErrorState", () => ({
ErrorState: ({ title }: { title: string }) => <div role="alert">{title}</div>,
}));

// ── 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<typeof useDividends>;

// ── 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<typeof useDividends>["data"] extends Array<infer T> | 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<typeof useDividends>;

function setupDividends(data: DividendsReturn["data"], extras: Partial<DividendsReturn> = {}) {
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(<DistributionPanel asset={makeAsset()} />);

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(<DistributionPanel asset={makeAsset()} />);

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(<DistributionPanel asset={makeAsset()} />);

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(<DistributionPanel asset={makeAsset()} />);

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(<DistributionPanel asset={makeAsset()} />);

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(<DistributionPanel asset={makeAsset()} />);

const bars = Array.from(
document.querySelectorAll<HTMLElement>(".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(<DistributionPanel asset={makeAsset()} />);

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(<DistributionPanel asset={makeAsset()} />);

expect(screen.getByRole("alert")).toBeInTheDocument();
});
});
Loading