diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..93f9e6e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,35 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: '[BUG] '
+labels: 'bug'
+assignees: ''
+
+---
+
+## Description
+A clear and concise description of what the bug is.
+
+## Steps to Reproduce
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '...'
+3. Scroll down to '...'
+4. See error
+
+## Expected Behavior
+A clear and concise description of what you expected to happen.
+
+## Actual Behavior
+A clear and concise description of what actually happened.
+
+## Screenshots
+If applicable, add screenshots to help explain your problem.
+
+## Environment
+- Browser: [e.g. Chrome, Firefox, Safari]
+- OS: [e.g. Windows, macOS, Linux]
+- Network/Testnet: [e.g. Testnet, Futurenet]
+
+## Additional Context
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..cbaf9fb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: '[FEATURE] '
+labels: 'enhancement'
+assignees: ''
+
+---
+
+## Description
+A clear and concise description of the feature you'd like to see.
+
+## Is your feature request related to a problem?
+A clear and concise description of any related problem. Ex. I'm always frustrated when [...]
+
+## Proposed Solution
+A clear and concise description of what you want to happen.
+
+## Alternatives Considered
+A clear and concise description of any alternative solutions or features you've considered.
+
+## Additional Context
+Add any other context or screenshots about the feature request here.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f9b7b16
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 RWA-ToolKit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/components/issuer/panels/CompliancePanel.test.tsx b/components/issuer/panels/CompliancePanel.test.tsx
new file mode 100644
index 0000000..ac1205a
--- /dev/null
+++ b/components/issuer/panels/CompliancePanel.test.tsx
@@ -0,0 +1,743 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { Keypair } from "@stellar/stellar-sdk";
+import type { AssetDetail } from "@/types";
+import { useTx } from "@/hooks/useTx";
+import { useAllowlist } from "@/hooks/useCompliance";
+import { CompliancePanel } from "./CompliancePanel";
+
+jest.mock("@/hooks/useTx", () => ({
+ useTx: jest.fn(),
+}));
+
+jest.mock("@/hooks/useCompliance", () => ({
+ useAllowlist: jest.fn(),
+}));
+
+jest.mock("@/lib/contracts", () => ({
+ compliance: {
+ addToAllowlist: jest.fn(),
+ suspend: jest.fn(),
+ remove: jest.fn(),
+ blockJurisdiction: jest.fn(),
+ unblockJurisdiction: jest.fn(),
+ },
+}));
+
+jest.mock("@/components/compliance/ComplianceBadge", () => ({
+ ComplianceBadge: ({ status }: { status: string }) => {status} ,
+}));
+
+jest.mock("@/components/ui/CopyButton", () => ({
+ CopyButton: ({ value }: { value: string }) => (
+ Copy
+ ),
+}));
+
+jest.mock("@/components/ui/TxProgress", () => ({
+ TxProgress: ({
+ phase,
+ hash,
+ error,
+ onDismiss,
+ successMessage,
+ }: {
+ phase: string;
+ hash: string | null;
+ error: string | null;
+ onDismiss: () => void;
+ successMessage: string;
+ }) => (
+
+
Phase: {phase}
+ {hash &&
Hash: {hash}
}
+ {error &&
Error: {error}
}
+
{successMessage}
+
Dismiss
+
+ ),
+}));
+
+jest.mock("@/components/ui/Spinner", () => ({
+ Spinner: ({ size }: { size?: number }) => ,
+}));
+
+const mockUseTx = useTx as jest.MockedFunction;
+const mockUseAllowlist = useAllowlist as jest.MockedFunction;
+
+const RECIPIENT = Keypair.random().publicKey();
+const CONTRACT_ADDRESS = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4";
+
+const createMockAsset = (overrides: Partial = {}): AssetDetail => ({
+ id: 1n,
+ tokenContract: "CTOKEN1",
+ issuer: "GISSUER",
+ name: "Test Asset",
+ assetType: "real_estate",
+ valuation: 1_000_000_00n,
+ createdAt: 50000,
+ active: true,
+ metadata: {
+ name: "Test Asset",
+ symbol: "TEST",
+ assetType: "real_estate",
+ totalSupply: 1_000_000n,
+ decimals: 7,
+ admin: "GISSUER",
+ complianceContract: "CCOMPLIANCE",
+ assetDescription: "Test asset",
+ valuation: 1_000_000_00n,
+ paused: false,
+ },
+ ...overrides,
+});
+
+function setupIdleTx() {
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ }),
+ reset: jest.fn(),
+ });
+}
+
+function setupEmptyAllowlist() {
+ mockUseAllowlist.mockReturnValue({
+ data: [],
+ loading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+}
+
+describe("CompliancePanel", () => {
+ afterEach(() => jest.clearAllMocks());
+
+ describe("AddToAllowlistCard", () => {
+ it("renders the address approval form", () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+ render( );
+
+ expect(screen.getByLabelText("Address")).toBeInTheDocument();
+ expect(screen.getByLabelText("Jurisdiction")).toBeInTheDocument();
+ expect(screen.getByLabelText("Expires at ledger")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Approve address" })
+ ).toBeInTheDocument();
+ });
+
+ it("rejects an invalid address", async () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Address"), {
+ target: { value: "not-an-address" },
+ });
+ fireEvent.change(screen.getByLabelText("Jurisdiction"), {
+ target: { value: "US" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve address" }));
+
+ expect(
+ screen.getByText("Enter a valid Stellar address (G… or C…).")
+ ).toBeInTheDocument();
+ });
+
+ it("accepts a valid G… address", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Jurisdiction"), {
+ target: { value: "US" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve address" }));
+
+ expect(mockRun).toHaveBeenCalled();
+ });
+
+ it("accepts a valid C… contract address", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Address"), {
+ target: { value: CONTRACT_ADDRESS },
+ });
+ fireEvent.change(screen.getByLabelText("Jurisdiction"), {
+ target: { value: "US" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve address" }));
+
+ expect(mockRun).toHaveBeenCalled();
+ });
+
+ it("rejects a jurisdiction code that is too short", async () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Jurisdiction"), {
+ target: { value: "U" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve address" }));
+
+ expect(
+ screen.getByText("Enter a 2–3 character ISO jurisdiction code (e.g. US, DE, KE).")
+ ).toBeInTheDocument();
+ });
+
+ it("rejects a jurisdiction code that is too long", async () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Jurisdiction"), {
+ target: { value: "USAA" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve address" }));
+
+ expect(
+ screen.getByText("Enter a 2–3 character ISO jurisdiction code (e.g. US, DE, KE).")
+ ).toBeInTheDocument();
+ });
+
+ it("rejects a non-numeric expiry ledger", async () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Jurisdiction"), {
+ target: { value: "US" },
+ });
+ fireEvent.change(screen.getByLabelText("Expires at ledger"), {
+ target: { value: "not-a-number" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve address" }));
+
+ expect(
+ screen.getByText("Expiry ledger must be a non-negative integer (0 = never expires).")
+ ).toBeInTheDocument();
+ });
+
+ it("rejects a negative expiry ledger", async () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Jurisdiction"), {
+ target: { value: "US" },
+ });
+ fireEvent.change(screen.getByLabelText("Expires at ledger"), {
+ target: { value: "-1" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve address" }));
+
+ expect(
+ screen.getByText("Expiry ledger must be a non-negative integer (0 = never expires).")
+ ).toBeInTheDocument();
+ });
+
+ it("accepts 0 as expiry ledger (never expires)", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Jurisdiction"), {
+ target: { value: "US" },
+ });
+ fireEvent.change(screen.getByLabelText("Expires at ledger"), {
+ target: { value: "0" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve address" }));
+
+ expect(mockRun).toHaveBeenCalled();
+ });
+
+ it("disables form while transaction is pending", () => {
+ mockUseTx.mockReturnValue({
+ phase: "signing",
+ hash: null,
+ error: null,
+ pending: true,
+ run: jest.fn(),
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ render( );
+
+ expect(screen.getByLabelText("Address")).toBeDisabled();
+ expect(screen.getByLabelText("Jurisdiction")).toBeDisabled();
+ expect(screen.getByLabelText("Expires at ledger")).toBeDisabled();
+ });
+
+ it("calls onChanged callback when approval succeeds", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ const onChanged = jest.fn();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Jurisdiction"), {
+ target: { value: "US" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Approve address" }));
+
+ expect(onChanged).toHaveBeenCalled();
+ });
+
+ it("converts jurisdiction to uppercase", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ render( );
+
+ const jurisdictionInput = screen.getByLabelText("Jurisdiction") as HTMLInputElement;
+ fireEvent.change(jurisdictionInput, {
+ target: { value: "us" },
+ });
+
+ expect(jurisdictionInput.value).toBe("US");
+ });
+ });
+
+ describe("AllowlistManageCard", () => {
+ it("shows loading spinner when allowlist is loading", () => {
+ setupIdleTx();
+ mockUseAllowlist.mockReturnValue({
+ data: null,
+ loading: true,
+ error: null,
+ refetch: jest.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByTestId("spinner")).toBeInTheDocument();
+ });
+
+ it("shows empty state when allowlist is empty", () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+
+ render( );
+
+ expect(screen.getByText("No addresses on the allowlist yet.")).toBeInTheDocument();
+ });
+
+ it("renders allowlist entries", () => {
+ setupIdleTx();
+ mockUseAllowlist.mockReturnValue({
+ data: [
+ {
+ address: RECIPIENT,
+ status: "Approved",
+ jurisdiction: "US",
+ },
+ ],
+ loading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByText("Approved")).toBeInTheDocument();
+ expect(screen.getByText("US")).toBeInTheDocument();
+ });
+
+ it("shows Suspend button for approved addresses", () => {
+ setupIdleTx();
+ mockUseAllowlist.mockReturnValue({
+ data: [
+ {
+ address: RECIPIENT,
+ status: "Approved",
+ jurisdiction: "US",
+ },
+ ],
+ loading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByRole("button", { name: "Suspend" })).toBeInTheDocument();
+ });
+
+ it("shows Re-approve button for suspended addresses", () => {
+ setupIdleTx();
+ mockUseAllowlist.mockReturnValue({
+ data: [
+ {
+ address: RECIPIENT,
+ status: "Suspended",
+ jurisdiction: "US",
+ },
+ ],
+ loading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByRole("button", { name: "Re-approve" })).toBeInTheDocument();
+ });
+
+ it("always shows Remove button", () => {
+ setupIdleTx();
+ mockUseAllowlist.mockReturnValue({
+ data: [
+ {
+ address: RECIPIENT,
+ status: "Approved",
+ jurisdiction: "US",
+ },
+ ],
+ loading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
+ render( );
+
+ const removeButtons = screen.getAllByRole("button", { name: "Remove" });
+ expect(removeButtons.length).toBeGreaterThan(0);
+ });
+
+ it("disables action buttons while transaction is pending", () => {
+ mockUseTx.mockReturnValue({
+ phase: "signing",
+ hash: null,
+ error: null,
+ pending: true,
+ run: jest.fn(),
+ reset: jest.fn(),
+ });
+ mockUseAllowlist.mockReturnValue({
+ data: [
+ {
+ address: RECIPIENT,
+ status: "Approved",
+ jurisdiction: "US",
+ },
+ ],
+ loading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByRole("button", { name: "Suspend" })).toBeDisabled();
+ });
+
+ it("calls onChanged callback when action succeeds", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ mockUseAllowlist.mockReturnValue({
+ data: [
+ {
+ address: RECIPIENT,
+ status: "Approved",
+ jurisdiction: "US",
+ },
+ ],
+ loading: false,
+ error: null,
+ refetch: jest.fn(),
+ });
+
+ const onChanged = jest.fn();
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: "Suspend" }));
+
+ expect(onChanged).toHaveBeenCalled();
+ });
+ });
+
+ describe("JurisdictionCard", () => {
+ it("renders block and unblock jurisdiction forms", () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+
+ render( );
+
+ expect(screen.getByLabelText("Block jurisdiction")).toBeInTheDocument();
+ expect(screen.getByLabelText("Unblock jurisdiction")).toBeInTheDocument();
+ });
+
+ it("rejects a jurisdiction code that is too short in block form", async () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+ render( );
+
+ const blockInput = screen.getByLabelText("Block jurisdiction");
+ fireEvent.change(blockInput, { target: { value: "U" } });
+
+ const blockButtons = screen.getAllByRole("button", { name: "Block" });
+ fireEvent.click(blockButtons[0]);
+
+ expect(screen.getByText("Enter a 2–3 character ISO code.")).toBeInTheDocument();
+ });
+
+ it("accepts a valid jurisdiction code in block form", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ render( );
+
+ const blockInput = screen.getByLabelText("Block jurisdiction");
+ fireEvent.change(blockInput, { target: { value: "KP" } });
+
+ const blockButtons = screen.getAllByRole("button", { name: "Block" });
+ fireEvent.click(blockButtons[0]);
+
+ expect(mockRun).toHaveBeenCalled();
+ });
+
+ it("accepts a valid jurisdiction code in unblock form", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ render( );
+
+ const unblockInput = screen.getByLabelText("Unblock jurisdiction");
+ fireEvent.change(unblockInput, { target: { value: "US" } });
+
+ const unblockButtons = screen.getAllByRole("button", { name: "Unblock" });
+ fireEvent.click(unblockButtons[0]);
+
+ expect(mockRun).toHaveBeenCalled();
+ });
+
+ it("converts jurisdiction codes to uppercase", () => {
+ setupIdleTx();
+ setupEmptyAllowlist();
+ render( );
+
+ const blockInput = screen.getByLabelText("Block jurisdiction") as HTMLInputElement;
+ fireEvent.change(blockInput, { target: { value: "kp" } });
+
+ expect(blockInput.value).toBe("KP");
+ });
+
+ it("disables block button while transaction is pending", () => {
+ mockUseTx.mockReturnValue({
+ phase: "signing",
+ hash: null,
+ error: null,
+ pending: true,
+ run: jest.fn(),
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ render( );
+
+ const blockButtons = screen.getAllByRole("button", { name: "Block" });
+ expect(blockButtons[0]).toBeDisabled();
+ });
+
+ it("disables unblock button while transaction is pending", () => {
+ mockUseTx.mockReturnValue({
+ phase: "signing",
+ hash: null,
+ error: null,
+ pending: true,
+ run: jest.fn(),
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ render( );
+
+ const unblockButtons = screen.getAllByRole("button", { name: "Unblock" });
+ expect(unblockButtons[0]).toBeDisabled();
+ });
+
+ it("calls onChanged callback when block succeeds", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ const onChanged = jest.fn();
+ render( );
+
+ const blockInput = screen.getByLabelText("Block jurisdiction");
+ fireEvent.change(blockInput, { target: { value: "KP" } });
+
+ const blockButtons = screen.getAllByRole("button", { name: "Block" });
+ fireEvent.click(blockButtons[0]);
+
+ expect(onChanged).toHaveBeenCalled();
+ });
+
+ it("calls onChanged callback when unblock succeeds", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+ setupEmptyAllowlist();
+
+ const onChanged = jest.fn();
+ render( );
+
+ const unblockInput = screen.getByLabelText("Unblock jurisdiction");
+ fireEvent.change(unblockInput, { target: { value: "US" } });
+
+ const unblockButtons = screen.getAllByRole("button", { name: "Unblock" });
+ fireEvent.click(unblockButtons[0]);
+
+ expect(onChanged).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/components/issuer/panels/TokenPanel.test.tsx b/components/issuer/panels/TokenPanel.test.tsx
new file mode 100644
index 0000000..12d5964
--- /dev/null
+++ b/components/issuer/panels/TokenPanel.test.tsx
@@ -0,0 +1,565 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { Keypair } from "@stellar/stellar-sdk";
+import type { AssetDetail } from "@/types";
+import { useTx } from "@/hooks/useTx";
+import { TokenPanel } from "./TokenPanel";
+
+jest.mock("@/hooks/useTx", () => ({
+ useTx: jest.fn(),
+}));
+
+jest.mock("@/lib/contracts", () => ({
+ assetToken: {
+ mint: jest.fn(),
+ pause: jest.fn(),
+ unpause: jest.fn(),
+ },
+}));
+
+jest.mock("@/components/ui/TxProgress", () => ({
+ TxProgress: ({
+ phase,
+ hash,
+ error,
+ onDismiss,
+ successMessage,
+ }: {
+ phase: string;
+ hash: string | null;
+ error: string | null;
+ onDismiss: () => void;
+ successMessage: string;
+ }) => (
+
+
Phase: {phase}
+ {hash &&
Hash: {hash}
}
+ {error &&
Error: {error}
}
+
{successMessage}
+
Dismiss
+
+ ),
+}));
+
+const mockUseTx = useTx as jest.MockedFunction;
+
+const RECIPIENT = Keypair.random().publicKey();
+
+const createMockAsset = (overrides: Partial = {}): AssetDetail => ({
+ id: 1n,
+ tokenContract: "CTOKEN1",
+ issuer: "GISSUER",
+ name: "Test Asset",
+ assetType: "real_estate",
+ valuation: 1_000_000_00n,
+ createdAt: 50000,
+ active: true,
+ metadata: {
+ name: "Test Asset",
+ symbol: "TEST",
+ assetType: "real_estate",
+ totalSupply: 1_000_000n,
+ decimals: 7,
+ admin: "GISSUER",
+ complianceContract: "CCOMPLIANCE",
+ assetDescription: "Test asset",
+ valuation: 1_000_000_00n,
+ paused: false,
+ },
+ ...overrides,
+});
+
+function setupIdleTx() {
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ }),
+ reset: jest.fn(),
+ });
+}
+
+describe("TokenPanel", () => {
+ afterEach(() => jest.clearAllMocks());
+
+ describe("MintCard", () => {
+ it("renders the mint form with address and amount fields", () => {
+ setupIdleTx();
+ render( );
+
+ expect(screen.getByLabelText("Recipient address")).toBeInTheDocument();
+ expect(screen.getByLabelText("Amount")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Mint" })).toBeInTheDocument();
+ });
+
+ it("displays the token symbol in the amount field", () => {
+ setupIdleTx();
+ render(
+
+ );
+
+ expect(screen.getByText("GOLD")).toBeInTheDocument();
+ });
+
+ it("displays the current token supply", () => {
+ setupIdleTx();
+ const asset = createMockAsset({
+ metadata: {
+ ...createMockAsset().metadata,
+ totalSupply: 5_000_000n,
+ decimals: 7,
+ symbol: "TEST",
+ },
+ });
+ render( );
+
+ expect(screen.getByText(/Current supply: 500 TEST/)).toBeInTheDocument();
+ });
+
+ it("rejects an invalid recipient address", async () => {
+ setupIdleTx();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Recipient address"), {
+ target: { value: "not-a-stellar-address" },
+ });
+ fireEvent.change(screen.getByLabelText("Amount"), {
+ target: { value: "100" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Mint" }));
+
+ expect(
+ screen.getByText("Enter a valid Stellar address (G… or C…).")
+ ).toBeInTheDocument();
+ });
+
+ it("accepts a valid G… address", async () => {
+ setupIdleTx();
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Recipient address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Amount"), {
+ target: { value: "100" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Mint" }));
+
+ expect(mockRun).toHaveBeenCalled();
+ });
+
+ it("accepts a valid C… contract address", async () => {
+ setupIdleTx();
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Recipient address"), {
+ target: { value: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4" },
+ });
+ fireEvent.change(screen.getByLabelText("Amount"), {
+ target: { value: "100" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Mint" }));
+
+ expect(mockRun).toHaveBeenCalled();
+ });
+
+ it("rejects a zero or negative amount", async () => {
+ setupIdleTx();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Recipient address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Amount"), {
+ target: { value: "0" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Mint" }));
+
+ expect(
+ screen.getByText("Amount must be greater than zero.")
+ ).toBeInTheDocument();
+ });
+
+ it("disables form inputs while a transaction is pending", () => {
+ mockUseTx.mockReturnValue({
+ phase: "signing",
+ hash: null,
+ error: null,
+ pending: true,
+ run: jest.fn(),
+ reset: jest.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByLabelText("Recipient address")).toBeDisabled();
+ expect(screen.getByLabelText("Amount")).toBeDisabled();
+ });
+
+ it("shows TxProgress when transaction is not idle", () => {
+ mockUseTx.mockReturnValue({
+ phase: "confirming",
+ hash: "abc123",
+ error: null,
+ pending: true,
+ run: jest.fn(),
+ reset: jest.fn(),
+ });
+
+ render( );
+
+ expect(screen.getByTestId("tx-progress")).toBeInTheDocument();
+ expect(screen.getByText(/Phase: confirming/)).toBeInTheDocument();
+ });
+
+ it("clears the form after successful mint", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+
+ const onMinted = jest.fn();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Recipient address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Amount"), {
+ target: { value: "100" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Mint" }));
+
+ expect(onMinted).toHaveBeenCalled();
+ });
+
+ it("calls onMinted callback when mint succeeds", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+
+ const onMinted = jest.fn();
+ render( );
+
+ fireEvent.change(screen.getByLabelText("Recipient address"), {
+ target: { value: RECIPIENT },
+ });
+ fireEvent.change(screen.getByLabelText("Amount"), {
+ target: { value: "100" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Mint" }));
+
+ expect(onMinted).toHaveBeenCalled();
+ });
+ });
+
+ describe("PauseCard", () => {
+ it("renders pause button when token is not paused", () => {
+ setupIdleTx();
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: "Pause transfers" })).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Unpause transfers" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders unpause button when token is paused", () => {
+ setupIdleTx();
+ render(
+
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Unpause transfers" })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Pause transfers" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("displays pause warning when token is paused", () => {
+ setupIdleTx();
+ render(
+
+ );
+
+ expect(
+ screen.getByText("Transfers are currently paused.")
+ ).toBeInTheDocument();
+ });
+
+ it("does not display pause warning when token is not paused", () => {
+ setupIdleTx();
+ render(
+
+ );
+
+ expect(
+ screen.queryByText("Transfers are currently paused.")
+ ).not.toBeInTheDocument();
+ });
+
+ it("disables pause button while transaction is pending", () => {
+ mockUseTx.mockReturnValue({
+ phase: "signing",
+ hash: null,
+ error: null,
+ pending: true,
+ run: jest.fn(),
+ reset: jest.fn(),
+ });
+
+ render(
+
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Pause transfers" })
+ ).toBeDisabled();
+ });
+
+ it("disables unpause button while transaction is pending", () => {
+ mockUseTx.mockReturnValue({
+ phase: "signing",
+ hash: null,
+ error: null,
+ pending: true,
+ run: jest.fn(),
+ reset: jest.fn(),
+ });
+
+ render(
+
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Unpause transfers" })
+ ).toBeDisabled();
+ });
+
+ it("shows TxProgress when pause transaction is not idle", () => {
+ mockUseTx.mockReturnValue({
+ phase: "confirming",
+ hash: "def456",
+ error: null,
+ pending: true,
+ run: jest.fn(),
+ reset: jest.fn(),
+ });
+
+ render(
+
+ );
+
+ expect(screen.getByTestId("tx-progress")).toBeInTheDocument();
+ });
+
+ it("calls onPauseToggled callback when pause succeeds", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+
+ const onPauseToggled = jest.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Pause transfers" }));
+
+ expect(onPauseToggled).toHaveBeenCalled();
+ });
+
+ it("calls onPauseToggled callback when unpause succeeds", async () => {
+ const mockRun = jest.fn(async (fn) => {
+ await fn({} as any);
+ return true;
+ });
+ mockUseTx.mockReturnValue({
+ phase: "idle",
+ hash: null,
+ error: null,
+ pending: false,
+ run: mockRun,
+ reset: jest.fn(),
+ });
+
+ const onPauseToggled = jest.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Unpause transfers" }));
+
+ expect(onPauseToggled).toHaveBeenCalled();
+ });
+
+ it("uses btn-primary styling for pause button", () => {
+ setupIdleTx();
+ render(
+
+ );
+
+ const pauseButton = screen.getByRole("button", { name: "Pause transfers" });
+ expect(pauseButton).toHaveClass("btn-secondary");
+ });
+
+ it("uses btn-primary styling for unpause button", () => {
+ setupIdleTx();
+ render(
+
+ );
+
+ const unpauseButton = screen.getByRole("button", { name: "Unpause transfers" });
+ expect(unpauseButton).toHaveClass("btn-primary");
+ });
+ });
+});