diff --git a/docs/API.md b/docs/API.md index 687af2bb..48f7de30 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2184,6 +2184,56 @@ soroban contract invoke \ --- +## Subscription Integrity Diagnostics + +Administrative utilities for detecting and repairing corrupted subscription records after migrations or contract upgrades. + +### `validate_subscription` + +Read-only integrity check for a subscriber address. + +``` +validate_subscription(env: Env, user: Address) -> SubscriptionValidationReport +``` + +**Returns `SubscriptionValidationReport`** + +| Field | Type | Description | +| --- | --- | --- | +| `is_valid` | `bool` | `true` when no inconsistencies are detected | +| `violations` | `Vec` | General integrity violations | +| `missing_records` | `Vec` | Missing auxiliary records (history, metadata, etc.) | +| `invalid_state_transitions` | `Vec` | Illegal active/paused/cancelled state combinations | +| `corrupted_references` | `Vec` | Broken merchant/token/referrer references | + +**Auth:** None (read-only simulation). + +**Frontend:** Exposed in the Admin Dashboard → Subscription Repair panel. + +--- + +### `repair_subscription` + +Repairs detected subscription inconsistencies for a user. + +``` +repair_subscription(env: Env, user: Address) -> u32 +``` + +**Auth:** Contract admin only (`require_admin`). + +**Returns:** Count of fixed inconsistencies (also emitted in the `subscription_repaired` event). + +**Event emitted** + +| Event name | Topic | Data | +| --- | --- | --- | +| `subscription_repaired` | `("subscription_repaired", user_address)` | `fixed_inconsistencies: u32` | + +**Frontend authorization:** The repair button is enabled only when the connected Freighter wallet matches the on-chain admin returned by `get_admin`. + +--- + ## Units & Conversions All amounts are in **stroops** — the smallest unit of a Stellar token. @@ -2219,6 +2269,7 @@ For a complete reference of all events with detailed schemas and examples, see [ | `paused` | `("paused", user_address)` | `()` | | `resumed` | `("resumed", user_address)` | `()` | | `referred` | `("referred", user_address)` | `referrer_address` | +| `subscription_repaired` | `("subscription_repaired", user_address)` | `fixed_inconsistencies: u32` | --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 01b1adda..3681c99f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -168,6 +168,27 @@ Events are the main off-chain integration surface for analytics, indexers, and t The frontend does not talk to the contract directly. `frontend/src/stellar.ts` builds and simulates Soroban transactions, then Freighter signs them. +``` +App.tsx +├── useWallet() — Freighter connection, signing, submission +├── SubscribeForm.tsx — form to create a subscription +├── Dashboard.tsx — view subscription, cancel, pay-per-use +├── MerchantDashboard.tsx — merchant subscriber management +└── AdminDashboard.tsx — admin diagnostics and subscription repair +``` + +### Admin repair workflow + +1. Admin connects Freighter wallet; `useAdmin` compares `publicKey` to on-chain `get_admin`. +2. Operator enters a subscriber address and runs `validate_subscription` via RPC simulation. +3. Violations are mapped to human-readable messages in the UI (missing records, invalid transitions, corrupted references). +4. If failures exist and the wallet is admin, `repair_subscription` is submitted after confirmation. +5. The UI parses the `subscription_repaired` event for the exact fixed-inconsistency count and re-runs validation. + +Authorization is enforced both in the UI (repair button disabled for non-admins) and on-chain (`require_admin` in the contract). + +All Soroban SDK calls are isolated in `stellar.ts`. Components never import `@stellar/stellar-sdk` directly. This makes it easy to swap the SDK version or mock it in tests. + Typical flows: - Subscribe: build transaction, simulate, sign, submit. diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index e90566d7..72a4f4d5 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -125,9 +125,17 @@ Form component for creating a new subscription. Accepts merchant address, XLM am Displays the user's active subscription. Shows merchant, amount, interval, and next charge date. Provides cancel and pay-per-use actions. +### `frontend/src/pages/AdminDashboard.tsx` + +Administrative tools layout. Hosts the Subscription Repair panel for on-chain diagnostics and recovery. + +### `frontend/src/components/admin/SubscriptionRepairPanel.tsx` + +Admin-only subscription integrity panel. Calls `validate_subscription` (read-only simulation) and `repair_subscription` (admin-auth transaction). Authorization is enforced by comparing the connected wallet to `get_admin`. + ### `frontend/src/App.tsx` -Root component. Manages wallet connection state and tab switching between `SubscribeForm` and `Dashboard`. +Root component. Manages wallet connection state and tab switching between `SubscribeForm`, `Dashboard`, `MerchantDashboard`, and `AdminDashboard`. --- diff --git a/docs/TESTING.md b/docs/TESTING.md index 5fcf8ce7..95c5a501 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -171,3 +171,12 @@ Frontend tests run with Vitest: cd frontend npm run test ``` + +### Admin subscription repair panel + +| Test file | Coverage | +| --- | --- | +| `subscriptionValidation.test.ts` | Violation formatting and failure detection | +| `useAdmin.test.tsx` | Admin wallet authorization | +| `SubscriptionRepairPanel.test.tsx` | Validation/repair UI states, event count display, unauthorized repair | +| `AdminDashboard.test.tsx` | Dashboard integration and read-only guidance | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 841efefb..bdf8e00f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { useAnalytics } from "./hooks/useAnalytics"; import SubscribeForm from "./components/SubscribeForm"; import Dashboard from "./components/Dashboard"; +import AdminDashboard from "./pages/AdminDashboard"; import SystemHealthCard from "./components/SystemHealthCard"; import TabBar from "./components/TabBar"; import ConnectWallet from "./components/ConnectWallet"; @@ -120,6 +121,7 @@ export default function App() { const subscribeErrorBoundaryRef = useRef(null); const dashboardErrorBoundaryRef = useRef(null); const merchantErrorBoundaryRef = useRef(null); + const adminErrorBoundaryRef = useRef(null); // Keyboard shortcuts const shortcuts = useKeyboardShortcuts({ @@ -410,7 +412,20 @@ export default function App() { ) : tab === "admin" ? ( - + adminErrorBoundaryRef.current?.reset()} + /> + } + > + <> + + + + ) : ( ({ + useSubscription: vi.fn(() => ({ + subscription: null, + loading: false, + error: null, + refresh: vi.fn(), + })), +})); +vi.mock("../hooks/useTransaction", () => ({ + useTransaction: vi.fn(() => ({ + status: "idle", + hash: null, + error: null, + submit: vi.fn(), + })), +})); + +import { useAdmin } from "../hooks/useAdmin"; +import AdminDashboard from "../pages/AdminDashboard"; + +describe("AdminDashboard", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useAdmin).mockReturnValue({ + adminAddress: "GADMIN123", + isAdmin: true, + loading: false, + error: null, + refresh: vi.fn(), + }); + }); + + it("renders the admin dashboard with subscription repair section", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Admin Dashboard")).toBeTruthy(); + }); + + expect(screen.getByText("Subscription Repair")).toBeTruthy(); + expect(screen.getByRole("button", { name: /validate subscription/i })).toBeTruthy(); + }); + + it("shows read-only guidance for non-admin wallets", async () => { + vi.mocked(useAdmin).mockReturnValue({ + adminAddress: "GADMIN123", + isAdmin: false, + loading: false, + error: null, + refresh: vi.fn(), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/Diagnostic tools are available in read-only mode/) + ).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/__tests__/SubscriptionRepairPanel.test.tsx b/frontend/src/__tests__/SubscriptionRepairPanel.test.tsx new file mode 100644 index 00000000..14b5fc19 --- /dev/null +++ b/frontend/src/__tests__/SubscriptionRepairPanel.test.tsx @@ -0,0 +1,192 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock("../stellar"); +vi.mock("../hooks/useAdmin"); +vi.mock("../hooks/useSubscription", () => ({ + useSubscription: vi.fn(() => ({ + subscription: null, + loading: false, + error: null, + refresh: vi.fn(), + })), +})); +vi.mock("../hooks/useTransaction", () => ({ + useTransaction: vi.fn(() => ({ + status: "idle", + hash: null, + error: null, + submit: vi.fn(async (fn) => fn()), + })), +})); + +import * as stellar from "../stellar"; +import { useAdmin } from "../hooks/useAdmin"; +import { useTransaction } from "../hooks/useTransaction"; +import SubscriptionRepairPanel from "../components/admin/SubscriptionRepairPanel"; + +const VALID_USER = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +describe("SubscriptionRepairPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(useAdmin).mockReturnValue({ + adminAddress: "GADMIN123", + isAdmin: true, + loading: false, + error: null, + refresh: vi.fn(), + }); + }); + + it("shows loading state while validating", async () => { + vi.mocked(stellar.validateSubscription).mockImplementation( + () => new Promise(() => {}) + ); + + render(); + + const input = screen.getByPlaceholderText("G…"); + await userEvent.type(input, VALID_USER); + await userEvent.click(screen.getByRole("button", { name: /validate subscription/i })); + + expect(screen.getByText(/Validating…/)).toBeTruthy(); + }); + + it("renders human-readable validation failures", async () => { + vi.mocked(stellar.validateSubscription).mockResolvedValue({ + isValid: false, + violations: ["missing_renewal_record", "invalid_subscription_status"], + missingRecords: [], + invalidStateTransitions: [], + corruptedReferences: [], + }); + + render(); + + await userEvent.type(screen.getByPlaceholderText("G…"), VALID_USER); + await userEvent.click(screen.getByRole("button", { name: /validate subscription/i })); + + await waitFor(() => { + expect(screen.getByText("Subscription Validation Failed")).toBeTruthy(); + }); + + expect(screen.getByText("Missing renewal record")).toBeTruthy(); + expect(screen.getByText("Invalid subscription status")).toBeTruthy(); + }); + + it("shows a passing validation state for clean subscriptions", async () => { + vi.mocked(stellar.validateSubscription).mockResolvedValue({ + isValid: true, + violations: [], + missingRecords: [], + invalidStateTransitions: [], + corruptedReferences: [], + }); + + render(); + + await userEvent.type(screen.getByPlaceholderText("G…"), VALID_USER); + await userEvent.click(screen.getByRole("button", { name: /validate subscription/i })); + + await waitFor(() => { + expect(screen.getByText(/Subscription validation passed/)).toBeTruthy(); + }); + }); + + it("disables repair for unauthorized wallets", async () => { + vi.mocked(useAdmin).mockReturnValue({ + adminAddress: "GADMIN123", + isAdmin: false, + loading: false, + error: null, + refresh: vi.fn(), + }); + + vi.mocked(stellar.validateSubscription).mockResolvedValue({ + isValid: false, + violations: ["corrupted_expiration_timestamp"], + missingRecords: [], + invalidStateTransitions: [], + corruptedReferences: [], + }); + + render(); + + await userEvent.type(screen.getByPlaceholderText("G…"), VALID_USER); + await userEvent.click(screen.getByRole("button", { name: /validate subscription/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /repair subscription/i })).toBeDisabled(); + }); + }); + + it("executes repair and displays fixed inconsistency count", async () => { + const onSign = vi.fn().mockResolvedValue("tx-hash-123"); + const submit = vi.fn(async (fn: () => Promise) => fn()); + + vi.mocked(useTransaction).mockReturnValue({ + status: "idle", + hash: null, + error: null, + submit, + }); + + vi.mocked(stellar.validateSubscription) + .mockResolvedValueOnce({ + isValid: false, + violations: ["missing_renewal_record"], + missingRecords: [], + invalidStateTransitions: [], + corruptedReferences: [], + }) + .mockResolvedValueOnce({ + isValid: true, + violations: [], + missingRecords: [], + invalidStateTransitions: [], + corruptedReferences: [], + }); + + vi.mocked(stellar.buildRepairSubscriptionTx).mockResolvedValue("signed-xdr"); + vi.mocked(stellar.parseSubscriptionRepairedEvent).mockResolvedValue(3); + + render(); + + await userEvent.type(screen.getByPlaceholderText("G…"), VALID_USER); + await userEvent.click(screen.getByRole("button", { name: /validate subscription/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /repair subscription/i })).toBeEnabled(); + }); + + await userEvent.click(screen.getByRole("button", { name: /repair subscription/i })); + await userEvent.click(screen.getByRole("button", { name: /^confirm$/i })); + + await waitFor(() => { + expect(stellar.buildRepairSubscriptionTx).toHaveBeenCalledWith("GADMIN123", VALID_USER); + }); + + await waitFor(() => { + expect(screen.getByText(/Fixed inconsistencies: 3/)).toBeTruthy(); + }); + }); + + it("shows validation error state with retry", async () => { + vi.mocked(stellar.validateSubscription).mockRejectedValue(new Error("HostError: Contract unavailable")); + + render(); + + await userEvent.type(screen.getByPlaceholderText("G…"), VALID_USER); + await userEvent.click(screen.getByRole("button", { name: /validate subscription/i })); + + await waitFor(() => { + expect(screen.getByText(/Validation Error/)).toBeTruthy(); + }); + + expect(screen.getByRole("button", { name: /retry validation/i })).toBeTruthy(); + }); +}); diff --git a/frontend/src/__tests__/subscriptionValidation.test.ts b/frontend/src/__tests__/subscriptionValidation.test.ts new file mode 100644 index 00000000..6e43f340 --- /dev/null +++ b/frontend/src/__tests__/subscriptionValidation.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { + collectValidationMessages, + formatViolation, + hasValidationFailures, +} from "../utils/subscriptionValidation"; +import type { SubscriptionValidationReport } from "../types"; + +describe("subscriptionValidation utils", () => { + it("formats known violation codes into human-readable messages", () => { + expect(formatViolation("missing_renewal_record")).toBe("Missing renewal record"); + expect(formatViolation("invalid_subscription_status")).toBe("Invalid subscription status"); + }); + + it("collects unique validation messages across categories", () => { + const report = { + violations: ["missing_renewal_record"], + missingRecords: ["missing_charge_history"], + invalidStateTransitions: ["invalid_state_transition"], + corruptedReferences: ["corrupted_token_reference"], + }; + + const messages = collectValidationMessages(report); + expect(messages).toContain("Missing renewal record"); + expect(messages).toContain("Missing charge history record"); + expect(messages).toContain("Invalid state transition"); + expect(messages).toContain("Corrupted token reference"); + expect(messages.length).toBe(4); + }); + + it("detects validation failures", () => { + const valid: SubscriptionValidationReport = { + isValid: true, + violations: [], + missingRecords: [], + invalidStateTransitions: [], + corruptedReferences: [], + }; + const invalid: SubscriptionValidationReport = { + ...valid, + isValid: false, + violations: ["invalid_subscription_status"], + }; + + expect(hasValidationFailures(valid)).toBe(false); + expect(hasValidationFailures(invalid)).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/useAdmin.test.tsx b/frontend/src/__tests__/useAdmin.test.tsx new file mode 100644 index 00000000..13daac9d --- /dev/null +++ b/frontend/src/__tests__/useAdmin.test.tsx @@ -0,0 +1,36 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { useAdmin } from "../hooks/useAdmin"; +import * as stellar from "../stellar"; + +vi.mock("../stellar", () => ({ + getContractAdmin: vi.fn(), +})); + +describe("useAdmin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("marks the connected wallet as admin when it matches on-chain admin", async () => { + vi.mocked(stellar.getContractAdmin).mockResolvedValue("GADMIN123"); + + const { result } = renderHook(() => useAdmin("GADMIN123")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isAdmin).toBe(true); + expect(result.current.adminAddress).toBe("GADMIN123"); + }); + + it("denies admin privileges for non-admin wallets", async () => { + vi.mocked(stellar.getContractAdmin).mockResolvedValue("GADMIN123"); + + const { result } = renderHook(() => useAdmin("GUSER456")); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isAdmin).toBe(false); + expect(result.current.adminAddress).toBe("GADMIN123"); + }); +}); diff --git a/frontend/src/components/admin/SubscriptionRepairPanel.tsx b/frontend/src/components/admin/SubscriptionRepairPanel.tsx new file mode 100644 index 00000000..d50be66b --- /dev/null +++ b/frontend/src/components/admin/SubscriptionRepairPanel.tsx @@ -0,0 +1,307 @@ +import React, { useCallback, useState } from "react"; +import { StrKey } from "@stellar/stellar-sdk"; +import { + buildRepairSubscriptionTx, + parseSubscriptionRepairedEvent, + validateSubscription, +} from "../../stellar"; +import { friendlyError } from "../../utils/errors"; +import { + collectValidationMessages, + formatViolation, + hasValidationFailures, +} from "../../utils/subscriptionValidation"; +import type { SubscriptionValidationReport } from "../../types"; +import { useAdmin } from "../../hooks/useAdmin"; +import { useSubscription } from "../../hooks/useSubscription"; +import { useTransaction } from "../../hooks/useTransaction"; +import { useToast } from "../../hooks/useToast"; +import AddressInput from "../AddressInput"; +import ConfirmModal from "../ConfirmModal"; +import Spinner from "../Spinner"; +import ToastContainer from "../Toast"; + +interface Props { + adminKey: string; + onSign: (xdr: string) => Promise; +} + +type ValidationPhase = "idle" | "loading" | "success" | "error"; + +function ViolationList({ items, prefix }: { items: string[]; prefix: string }) { + if (items.length === 0) return null; + + return ( +
    + {items.map((item) => ( +
  • {formatViolation(item)}
  • + ))} +
+ ); +} + +export default function SubscriptionRepairPanel({ adminKey, onSign }: Props) { + const { isAdmin, adminAddress, loading: adminLoading, error: adminError } = useAdmin(adminKey); + const { toasts, addToast, removeToast } = useToast(); + const repairTx = useTransaction(); + + const [userAddress, setUserAddress] = useState(""); + const [validatedAddress, setValidatedAddress] = useState(null); + const [report, setReport] = useState(null); + const [validationPhase, setValidationPhase] = useState("idle"); + const [validationError, setValidationError] = useState(null); + const [showRepairConfirm, setShowRepairConfirm] = useState(false); + const [repairResultCount, setRepairResultCount] = useState(null); + const [subscriptionRefresh, setSubscriptionRefresh] = useState(0); + + const lookupKey = validatedAddress ?? adminKey; + const { subscription, refresh: refreshSubscription } = useSubscription( + lookupKey, + subscriptionRefresh + ); + + const addressValid = + !!userAddress && StrKey.isValidEd25519PublicKey(userAddress.trim()); + + const validationMessages = report ? collectValidationMessages(report) : []; + const hasFailures = report ? hasValidationFailures(report) : false; + const canRepair = + isAdmin && + hasFailures && + !!validatedAddress && + repairTx.status !== "pending"; + + const runValidation = useCallback(async () => { + const trimmed = userAddress.trim(); + if (!StrKey.isValidEd25519PublicKey(trimmed)) { + setValidationError("Enter a valid Stellar public key (G…)."); + return; + } + + setValidationPhase("loading"); + setValidationError(null); + setReport(null); + setRepairResultCount(null); + setValidatedAddress(trimmed); + + try { + const result = await validateSubscription(adminKey, trimmed); + setReport(result); + setValidationPhase("success"); + } catch (e: unknown) { + const msg = friendlyError(e instanceof Error ? e.message : String(e)); + setValidationError(msg); + setValidationPhase("error"); + } + }, [adminKey, userAddress]); + + async function performRepair() { + if (!validatedAddress || !canRepair) return; + + setShowRepairConfirm(false); + setRepairResultCount(null); + + try { + const hash = await repairTx.submit(async () => { + const xdr = await buildRepairSubscriptionTx(adminKey, validatedAddress); + return onSign(xdr); + }); + + const fixedCount = await parseSubscriptionRepairedEvent(hash); + setRepairResultCount(fixedCount); + + if (fixedCount != null) { + addToast(`Repair successful. Fixed inconsistencies: ${fixedCount}`, "success", hash); + } else { + addToast("Repair transaction confirmed.", "success", hash); + } + + setSubscriptionRefresh((n) => n + 1); + await runValidation(); + await refreshSubscription(); + } catch (e: unknown) { + const msg = friendlyError(e instanceof Error ? e.message : String(e)); + addToast(`Repair failed: ${msg}`, "error"); + } + } + + return ( +
+ + +
+

+ Subscription Repair +

+

+ Diagnose corrupted subscription records and execute authorized on-chain repairs. +

+
+ + {adminLoading && ( +
+ + Verifying admin credentials… +
+ )} + + {!adminLoading && !isAdmin && ( +
+ 🔒 + + {adminError + ? adminError + : adminAddress + ? "Connected wallet is not the contract admin. Repair actions are disabled." + : "Admin credentials could not be verified. Repair actions are disabled."} + +
+ )} + + {isAdmin && ( +

+ Signed in as contract admin{" "} + {adminAddress?.slice(0, 8)}… +

+ )} + +
+ + +
+ + {validationPhase === "error" && validationError && ( +
+

Validation Error

+

{validationError}

+ +
+ )} + + {validationPhase === "success" && report && ( +
+ {hasFailures ? ( + <> +

+ Subscription Validation Failed +

+
    + {validationMessages.map((message) => ( +
  • {message}
  • + ))} +
+ + {report.missingRecords.length > 0 && ( +
+ + Missing records ({report.missingRecords.length}) + + +
+ )} + + {report.invalidStateTransitions.length > 0 && ( +
+ + Invalid state transitions ({report.invalidStateTransitions.length}) + + +
+ )} + + {report.corruptedReferences.length > 0 && ( +
+ + Corrupted references ({report.corruptedReferences.length}) + + +
+ )} + + + + {!isAdmin && ( +

+ Repair requires authorization from the contract admin wallet. +

+ )} + + ) : ( + <> +

+ Subscription validation passed +

+

+ No structural inconsistencies were detected for this address. +

+ + )} + + {repairResultCount != null && ( +
+ Repair successful +

Fixed inconsistencies: {repairResultCount}

+
+ )} + + {validatedAddress && subscription && ( +
+ Current subscription state +
{JSON.stringify(subscription, null, 2)}
+
+ )} +
+ )} + + {validationPhase === "idle" && ( +

+ Enter a subscriber address and run validation to inspect on-chain integrity. +

+ )} + + {showRepairConfirm && ( + setShowRepairConfirm(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts new file mode 100644 index 00000000..cb3410e5 --- /dev/null +++ b/frontend/src/hooks/useAdmin.ts @@ -0,0 +1,52 @@ +import { useState, useEffect, useCallback } from "react"; +import { getContractAdmin } from "../stellar"; + +export interface UseAdminResult { + adminAddress: string | null; + isAdmin: boolean; + loading: boolean; + error: string | null; + refresh: () => Promise; +} + +/** + * Resolves contract admin status by comparing the connected wallet + * against the on-chain admin address returned by `get_admin`. + */ +export function useAdmin(publicKey: string | null): UseAdminResult { + const [adminAddress, setAdminAddress] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!publicKey) { + setAdminAddress(null); + setError(null); + return; + } + + setLoading(true); + setError(null); + + try { + const admin = await getContractAdmin(publicKey); + setAdminAddress(admin); + if (!admin) { + setError("Contract admin is not configured."); + } + } catch (e: unknown) { + setAdminAddress(null); + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, [publicKey]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const isAdmin = !!publicKey && !!adminAddress && publicKey === adminAddress; + + return { adminAddress, isAdmin, loading, error, refresh }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5f7fb3e7..989a7f75 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -786,3 +786,21 @@ input::placeholder { align-items: center; gap: var(--space-2); } + +/* Admin subscription repair panel */ +.subscription-repair-panel__violations { + margin: 0; + padding-left: 1.25rem; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.subscription-repair-panel__violations li { + color: var(--color-text); + line-height: 1.5; +} + +.admin-dashboard__section { + margin-top: var(--space-4); +} diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx new file mode 100644 index 00000000..784a17d5 --- /dev/null +++ b/frontend/src/pages/AdminDashboard.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import SubscriptionRepairPanel from "../components/admin/SubscriptionRepairPanel"; +import { useAdmin } from "../hooks/useAdmin"; +import Spinner from "../components/Spinner"; + +interface Props { + publicKey: string; + onSign: (xdr: string) => Promise; +} + +export default function AdminDashboard({ publicKey, onSign }: Props) { + const { isAdmin, adminAddress, loading } = useAdmin(publicKey); + + return ( +
+
+
+

Admin Dashboard

+

+ Operational tools for contract administration and data-integrity recovery. +

+
+
+ + {loading ? ( +
+ + Loading admin context… +
+ ) : ( +

+ {isAdmin + ? `Authorized as contract admin (${adminAddress?.slice(0, 8)}…).` + : "Diagnostic tools are available in read-only mode. Repair actions require the contract admin wallet."} +

+ )} + +
+ +
+
+ ); +} diff --git a/frontend/src/stellar.ts b/frontend/src/stellar.ts index b1c31c89..31612756 100644 --- a/frontend/src/stellar.ts +++ b/frontend/src/stellar.ts @@ -14,7 +14,7 @@ import { xdr, } from "@stellar/stellar-sdk"; import { Server, assembleTransaction } from "@stellar/stellar-sdk/rpc"; -import type { Subscription, ChargeEvent } from "./types"; +import type { Subscription, ChargeEvent, SubscriptionValidationReport } from "./types"; import { ScValDecoder } from "./services/scval"; import { dedupedCall } from "./services/rpcCache"; @@ -254,10 +254,6 @@ export function getDailySpent(user: string): Promise { const retval = (result as { result?: { retval?: xdr.ScVal } }).result?.retval; if (!retval) return 0n; -<<<<<<< HEAD -======= - return ScValDecoder.decodeI128(retval); ->>>>>>> 6d2bb0bdee2f908481093df56db7a244c0dd0e50 try { return ScValDecoder.decodeI128(retval); } catch { @@ -313,11 +309,6 @@ export function getSubscription(user: string): Promise { const retval = (result as { result?: { retval?: xdr.ScVal } }).result?.retval; if (!retval || retval.switch().name === "scvVoid") return null; -<<<<<<< HEAD - if (retval.switch().name === "scvVoid") return null; - -======= ->>>>>>> 6d2bb0bdee2f908481093df56db7a244c0dd0e50 const subscriptionData = ScValDecoder.decodeStruct(retval, { merchant: ScValDecoder.decodeAddress, amount: (v) => ScValDecoder.decodeI128(v).toString(), @@ -533,15 +524,11 @@ export function getMerchantRevenue(merchant: string): Promise { const retval = (result as { result?: { retval?: xdr.ScVal } }).result?.retval; if (!retval) return 0n; -<<<<<<< HEAD try { return ScValDecoder.decodeI128(retval); } catch { return 0n; } -======= - return ScValDecoder.decodeI128(retval); ->>>>>>> 6d2bb0bdee2f908481093df56db7a244c0dd0e50 } catch { return 0n; } @@ -593,15 +580,11 @@ export function getAllowance(owner: string, tokenId = TOKEN_CONTRACT_ID): Promis const retval = (result as { result?: { retval?: xdr.ScVal } }).result?.retval; if (!retval) return 0n; -<<<<<<< HEAD try { return ScValDecoder.decodeI128(retval); } catch { return 0n; } -======= - return ScValDecoder.decodeI128(retval); ->>>>>>> 6d2bb0bdee2f908481093df56db7a244c0dd0e50 } catch { return 0n; } @@ -646,14 +629,9 @@ export async function fetchEvents( return { events, -<<<<<<< HEAD - nextCursor: response.events.length > 0 ? response.events[response.events.length - 1].pagingToken : undefined, -======= - nextCursor: undefined, nextCursor: response.latestLedger > 0 && response.events.length > 0 ? response.events[response.events.length - 1].pagingToken : undefined, ->>>>>>> 6d2bb0bdee2f908481093df56db7a244c0dd0e50 }; } catch { return { events: [] }; @@ -705,6 +683,7 @@ export async function getChargeHistory(user: string): Promise { } } +// ── Admin diagnostics ─────────────────────────────────────────────────────── export interface ContractHealthReport { rpcReachable: boolean; @@ -771,3 +750,213 @@ export async function getContractHealth(caller: string): Promise { + const account = await server.getAccount(sourcePublicKey); + const contract = new Contract(CONTRACT_ID); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build(); + + const simPromise = server.simulateTransaction(tx); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Validation request timed out")), timeoutMs); + }); + + const result = await Promise.race([simPromise, timeoutPromise]); + if ("error" in result) throw new Error(result.error); + + return (result as { result?: { retval?: xdr.ScVal } }).result?.retval ?? null; +} + +/** Returns the configured contract admin address, or null if unset. */ +export async function getContractAdmin(sourcePublicKey: string): Promise { + if (!CONTRACT_ID) throw new Error("VITE_CONTRACT_ID is not configured."); + + try { + const retval = await simulateContractRead(sourcePublicKey, "get_admin", []); + if (!retval || retval.switch().name === "scvVoid") return null; + + if (retval.switch().name === "scvAddress") { + return Address.fromScVal(retval).toString(); + } + + // Option
+ const inner = (retval as any).value?.() ?? (retval as any)._value; + if (inner) { + return Address.fromScVal(inner).toString(); + } + + return null; + } catch { + return null; + } +} + +/** Runs on-chain subscription integrity diagnostics for a user address. */ +export async function validateSubscription( + sourcePublicKey: string, + userAddress: string +): Promise { + if (!CONTRACT_ID) throw new Error("VITE_CONTRACT_ID is not configured."); + + const retval = await simulateContractRead(sourcePublicKey, "validate_subscription", [ + addressVal(userAddress), + ]); + + if (!retval) { + throw new Error("Contract returned no validation result"); + } + + return parseValidationReport(retval); +} + +export async function buildRepairSubscriptionTx( + adminPublicKey: string, + userAddress: string +): Promise { + return buildTx(adminPublicKey, "repair_subscription", [addressVal(userAddress)]); +} + +function parseFixedInconsistenciesFromEventValue(value: unknown): number | null { + if (value == null) return null; + + const raw = value as any; + + if (typeof raw === "number") return raw; + if (typeof raw === "bigint") return Number(raw); + + const direct = + raw?._value?.fixed_inconsistencies ?? + raw?.fixed_inconsistencies ?? + raw?._value ?? + raw; + + if (typeof direct === "number") return direct; + if (typeof direct === "bigint") return Number(direct); + + if (typeof direct?.u32 === "function") return Number(direct.u32()); + if (typeof direct?.toString === "function" && /^\d+$/.test(direct.toString())) { + return Number(direct.toString()); + } + + return null; +} + +/** Extracts the fixed inconsistency count from a `subscription_repaired` contract event. */ +export async function parseSubscriptionRepairedEvent(txHash: string): Promise { + try { + const tx = await server.getTransaction(txHash); + if (tx.status !== "SUCCESS") return null; + + const events = (tx as { events?: Array<{ topic?: unknown[]; value?: unknown }> }).events ?? []; + + for (const event of events) { + const topicName = event.topic?.[0]?.toString?.() ?? String(event.topic?.[0] ?? ""); + if (topicName !== "subscription_repaired") continue; + + const count = parseFixedInconsistenciesFromEventValue(event.value); + if (count != null && !Number.isNaN(count)) { + return count; + } + } + + // Fallback: scan recent contract events tied to this transaction hash. + const response = await server.getEvents({ + startLedger: undefined, + filters: [{ type: "contract", contractIds: [CONTRACT_ID] }], + limit: 50, + }); + + for (const event of response.events) { + if ((event as any).txHash !== txHash && (event as any).id !== txHash) continue; + const topicName = event.topic?.[0]?.toString?.() ?? ""; + if (topicName !== "subscription_repaired") continue; + + const count = parseFixedInconsistenciesFromEventValue(event.value); + if (count != null && !Number.isNaN(count)) { + return count; + } + } + + return null; + } catch { + return null; + } +} + + diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2084ce34..e85154a9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -15,3 +15,12 @@ export interface ChargeEvent { txHash: string; merchant: string; } + +/** On-chain diagnostic report returned by `validate_subscription`. */ +export interface SubscriptionValidationReport { + isValid: boolean; + violations: string[]; + missingRecords: string[]; + invalidStateTransitions: string[]; + corruptedReferences: string[]; +} diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts index cb889d5b..c33eea0f 100644 --- a/frontend/src/utils/errors.ts +++ b/frontend/src/utils/errors.ts @@ -5,6 +5,8 @@ export const CONTRACT_ERRORS: Record = { "already initialized": "Contract is already set up.", "amount must be positive": "Amount must be greater than zero.", "interval must be positive": "Billing interval must be greater than zero.", + "admin not set": "Contract admin is not configured.", + "require_auth": "Wallet authorization required. Connect as the contract admin.", }; export function friendlyError(raw: string): string { diff --git a/frontend/src/utils/subscriptionValidation.ts b/frontend/src/utils/subscriptionValidation.ts new file mode 100644 index 00000000..ec84bc8e --- /dev/null +++ b/frontend/src/utils/subscriptionValidation.ts @@ -0,0 +1,86 @@ +import type { SubscriptionValidationReport } from "../types"; + +/** + * Maps contract violation codes to operator-friendly diagnostic messages. + */ +const VIOLATION_MESSAGES: Record = { + missing_renewal_record: "Missing renewal record", + missing_subscription_record: "Missing subscription record", + missing_charge_history: "Missing charge history record", + invalid_subscription_status: "Invalid subscription status", + corrupted_expiration_timestamp: "Corrupted expiration timestamp", + corrupted_last_charged: "Corrupted last-charged timestamp", + invalid_state_transition: "Invalid state transition", + corrupted_merchant_reference: "Corrupted merchant reference", + corrupted_token_reference: "Corrupted token reference", + corrupted_referrer_reference: "Corrupted referrer reference", + schema_version_mismatch: "Schema version mismatch", + orphaned_metadata: "Orphaned metadata record", + no_subscription_found: "No subscription found for this address", +}; + +function normalizeKey(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/\s+/g, "_") + .replace(/[^a-z0-9_]/g, ""); +} + +/** Convert a contract violation code or message into human-readable text. */ +export function formatViolation(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return "Unknown validation issue"; + + const key = normalizeKey(trimmed); + if (VIOLATION_MESSAGES[key]) { + return VIOLATION_MESSAGES[key]; + } + + // Already human-readable (contains spaces and no snake_case-only pattern) + if (/\s/.test(trimmed) && !/^[a-z0-9_]+$/.test(trimmed)) { + return trimmed; + } + + return trimmed + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export function collectValidationMessages(report: { + violations: string[]; + missingRecords: string[]; + invalidStateTransitions: string[]; + corruptedReferences: string[]; +}): string[] { + const all = [ + ...report.violations, + ...report.missingRecords, + ...report.invalidStateTransitions, + ...report.corruptedReferences, + ]; + + const seen = new Set(); + const messages: string[] = []; + + for (const item of all) { + const formatted = formatViolation(item); + if (!seen.has(formatted)) { + seen.add(formatted); + messages.push(formatted); + } + } + + return messages; +} + +export function hasValidationFailures(report: SubscriptionValidationReport): boolean { + return ( + !report.isValid || + report.violations.length > 0 || + report.missingRecords.length > 0 || + report.invalidStateTransitions.length > 0 || + report.corruptedReferences.length > 0 + ); +}