diff --git a/CLAUDE.md b/CLAUDE.md
index 03bae68..eafba73 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -78,5 +78,5 @@ Four Soroban contracts sharing `soroban-sdk 23.1.1`. Build with `cargo build --r
## Environment Variables
Each app has a `.env.example`. Key vars:
-- **Frontend apps**: `NEXT_PUBLIC_API_KEY`, `NEXT_PUBLIC_API_URL`, `SOURCE_SECRET`
-- **Core**: `DATABASE_URL`, `PORT`, `SOURCE_SECRET`, `SOROBAN_RPC_URL`, `*_WASM_HASH` (contract hashes)
+- **Frontend apps**: `NEXT_PUBLIC_API_KEY`, `NEXT_PUBLIC_API_URL`, `SOURCE_SECRET`, `NEXT_PUBLIC_SOROBAN_RPC_URL`, `NEXT_PUBLIC_DEFAULT_USDC_ADDRESS`
+- **Core**: `DATABASE_URL`, `PORT`, `SOURCE_SECRET`, `SOROBAN_RPC_URL`, `USDC_CONTRACT_ID`, `*_WASM_HASH` (contract hashes)
diff --git a/apps/backoffice-tokenization/.env.example b/apps/backoffice-tokenization/.env.example
index aff2275..ebbf702 100644
--- a/apps/backoffice-tokenization/.env.example
+++ b/apps/backoffice-tokenization/.env.example
@@ -1,11 +1,11 @@
-# Trustless Work API Configuration
-NEXT_PUBLIC_API_KEY=
+# Trustless Work API
+NEXT_PUBLIC_API_KEY=""
-# Server-side only (for contract deployment)
-SOURCE_SECRET=
+# Core API (NestJS backend)
+NEXT_PUBLIC_CORE_API_URL="http://localhost:4000"
+NEXT_PUBLIC_BACKOFFICE_API_KEY=""
-# Core API URL (NestJS backend)
-NEXT_PUBLIC_CORE_API_URL=http://localhost:4000
-
-# Core API Authentication
-NEXT_PUBLIC_BACKOFFICE_API_KEY=
+# Soroban / Stellar network
+NEXT_PUBLIC_SOROBAN_RPC_URL="https://soroban-testnet.stellar.org"
+NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
+NEXT_PUBLIC_DEFAULT_USDC_ADDRESS="CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"
\ No newline at end of file
diff --git a/apps/backoffice-tokenization/messages/en.json b/apps/backoffice-tokenization/messages/en.json
index 96db946..ac01d41 100644
--- a/apps/backoffice-tokenization/messages/en.json
+++ b/apps/backoffice-tokenization/messages/en.json
@@ -76,6 +76,8 @@
"filterAll": "All",
"filterFundraising": "Fundraising",
"filterActive": "Active",
+ "filterRepayment": "Repayment",
+ "filterClaimable": "Claimable",
"filterClosed": "Closed",
"searchPlaceholder": "Search campaigns...",
"status": {
@@ -157,6 +159,7 @@
"statusHeader": "Status",
"actions": "Actions",
"manageLoans": "Manage Loans",
+ "viewVault": "View Vault",
"uploadFunds": "Upload Funds",
"updateRoi": "Update ROI",
"loadMore": "Load More",
@@ -189,6 +192,19 @@
"disable": "Disable",
"enabled": "Vault enabled",
"disabled": "Vault disabled"
+ },
+ "updateRoiDialog": {
+ "title": "Update ROI Percentage — {campaignName}",
+ "description": "Set a new ROI percentage for this campaign's vault. Value must be between 0 and 100.",
+ "fieldLabel": "ROI Percentage (%)",
+ "placeholder": "e.g. 12",
+ "updatingLabel": "Updating ROI...",
+ "submitLabel": "Update ROI Percentage",
+ "successToast": "ROI percentage updated successfully"
+ },
+ "errors": {
+ "walletNotConnected": "Wallet not connected",
+ "unexpectedError": "Unexpected error"
}
},
"tokens": {
@@ -238,6 +254,13 @@
"vaultContractAddress": "Vault Contract Address",
"copied": "Copied!",
"copy": "Copy",
+ "enableVaultSuccessToast": "Vault enabled successfully",
+ "errors": {
+ "failedBuildEnableTx": "Failed to build enable transaction.",
+ "transactionSubmissionFailed": "Transaction submission failed.",
+ "enableVaultRequestFailed": "Enable vault request did not succeed",
+ "unexpectedError": "Unexpected error"
+ },
"validation": {
"priceRequired": "Price is required",
"maxPrice": "Cannot exceed 100%",
diff --git a/apps/backoffice-tokenization/messages/es.json b/apps/backoffice-tokenization/messages/es.json
index 1f7e1d9..ed4ac0c 100644
--- a/apps/backoffice-tokenization/messages/es.json
+++ b/apps/backoffice-tokenization/messages/es.json
@@ -76,6 +76,8 @@
"filterAll": "Todas",
"filterFundraising": "Recaudando",
"filterActive": "Activa",
+ "filterRepayment": "En Pago",
+ "filterClaimable": "Reclamable",
"filterClosed": "Cerrada",
"searchPlaceholder": "Buscar campañas...",
"status": {
@@ -157,6 +159,7 @@
"statusHeader": "Estado",
"actions": "Acciones",
"manageLoans": "Gestionar Préstamos",
+ "viewVault": "Ver vault",
"uploadFunds": "Subir Fondos",
"updateRoi": "Actualizar ROI",
"loadMore": "Cargar Más",
@@ -189,6 +192,19 @@
"disable": "Deshabilitar",
"enabled": "Vault habilitado",
"disabled": "Vault deshabilitado"
+ },
+ "updateRoiDialog": {
+ "title": "Actualizar porcentaje de ROI — {campaignName}",
+ "description": "Establezca un nuevo porcentaje de ROI para el vault de esta campaña. El valor debe estar entre 0 y 100.",
+ "fieldLabel": "Porcentaje de ROI (%)",
+ "placeholder": "ej. 12",
+ "updatingLabel": "Actualizando ROI...",
+ "submitLabel": "Actualizar porcentaje de ROI",
+ "successToast": "El porcentaje de ROI se actualizó correctamente"
+ },
+ "errors": {
+ "walletNotConnected": "Wallet no conectada",
+ "unexpectedError": "Error inesperado"
}
},
"tokens": {
@@ -238,6 +254,13 @@
"vaultContractAddress": "Dirección del Contrato Vault",
"copied": "¡Copiado!",
"copy": "Copiar",
+ "enableVaultSuccessToast": "Vault habilitado exitosamente",
+ "errors": {
+ "failedBuildEnableTx": "No se pudo construir la transacción para habilitar el vault.",
+ "transactionSubmissionFailed": "Falló el envío de la transacción.",
+ "enableVaultRequestFailed": "La solicitud para habilitar el vault no fue exitosa",
+ "unexpectedError": "Error inesperado"
+ },
"validation": {
"priceRequired": "El precio es requerido",
"maxPrice": "No puede exceder 100%",
diff --git a/apps/backoffice-tokenization/next.config.ts b/apps/backoffice-tokenization/next.config.ts
index e3fcf88..53534af 100644
--- a/apps/backoffice-tokenization/next.config.ts
+++ b/apps/backoffice-tokenization/next.config.ts
@@ -5,7 +5,12 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
reactCompiler: true,
- transpilePackages: ["@tokenization/shared", "@tokenization/ui", "@tokenization/tw-blocks-shared"],
+ transpilePackages: [
+ "@tokenization/shared",
+ "@tokenization/ui",
+ "@tokenization/features",
+ "@tokenization/tw-blocks-shared",
+ ],
turbopack: {
root: "../../",
},
diff --git a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/loans/[id]/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/loans/[id]/page.tsx
index cb4f553..4261aa3 100644
--- a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/loans/[id]/page.tsx
+++ b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/loans/[id]/page.tsx
@@ -1,18 +1,23 @@
-import { SectionTitle } from "@/components/shared/section-title";
+ "use client";
+
+import { SectionTitle } from "@tokenization/ui/section-title";
import { ManageLoansView } from "@/features/campaigns/components/loans/manage-loans-view";
+import { useTranslations } from "next-intl";
+import { use } from "react";
interface Props {
params: Promise<{ id: string }>;
}
-export default async function CampaignLoansPage({ params }: Props) {
- const { id } = await params;
+export default function CampaignLoansPage({ params }: Props) {
+ const { id } = use(params);
+ const t = useTranslations("loans");
return (
diff --git a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/new/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/new/page.tsx
index fd5f50f..8778f70 100644
--- a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/new/page.tsx
+++ b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/new/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { SectionTitle } from "@/components/shared/section-title";
+import { SectionTitle } from "@tokenization/ui/section-title";
import { CreateCampaignStepper } from "@/features/campaigns/components/create/create-campaign-stepper";
import { useTranslations } from "next-intl";
diff --git a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/page.tsx
index 9bc8973..25507cc 100644
--- a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/page.tsx
+++ b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/campaigns/page.tsx
@@ -1,33 +1,77 @@
"use client";
import { Link } from "@/i18n/navigation";
-import { SectionTitle } from "@/components/shared/section-title";
-import { CampaignsView } from "@/features/campaigns/components/campaigns-view";
+import { SharedCampaignsView } from "@tokenization/features/campaign";
import { Button } from "@tokenization/ui/button";
import { Plus } from "lucide-react";
import { useTranslations } from "next-intl";
+import type { SharedCampaign } from "@tokenization/shared/src/types/campaign";
+import { useCampaigns } from "@/features/campaigns/hooks/use-campaigns";
+import { CampaignList } from "@/features/campaigns/components/campaign-list";
+import type { Campaign } from "@/features/campaigns/types/campaign.types";
+
+function backofficeToShared(c: Campaign): SharedCampaign {
+ return {
+ id: c.id,
+ title: c.name,
+ description: c.description ?? "",
+ status: c.status,
+ loansCompleted: 0,
+ investedAmount: 0,
+ currency: "USDC",
+ vaultId: c.vaultId ?? null,
+ escrowId: c.escrowId,
+ poolSize: c.poolSize,
+ };
+}
export default function CampaignsPage() {
const t = useTranslations("campaigns");
- return (
-
-
-
-
+ const { data: rawCampaigns = [], isLoading, isError } = useCampaigns();
+ const campaigns: SharedCampaign[] = rawCampaigns.map(backofficeToShared);
-
-
+ const toolbarLabels = {
+ searchPlaceholder: t("searchPlaceholder"),
+ filterAll: t("filterAll"),
+ filterFundraising: t("filterFundraising"),
+ filterActive: t("filterActive"),
+ filterRepayment: t("filterRepayment"),
+ filterClaimable: t("filterClaimable"),
+ filterClosed: t("filterClosed"),
+ };
+
+ if (isError) {
+ return (
+
+ {t("loadError")}
-
-
+ );
+ }
+
+ return (
+
+
+
+ {t("newCampaign")}
+
+
+ }
+ toolbarLabels={toolbarLabels}
+ >
+ {(filteredCampaigns: SharedCampaign[]) => {
+ const ids = new Set(filteredCampaigns.map((c) => c.id));
+ const rawFiltered = rawCampaigns.filter((r) => ids.has(r.id));
+ return ;
+ }}
+
);
}
diff --git a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/roi/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/roi/page.tsx
index da12dcf..f03fa50 100644
--- a/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/roi/page.tsx
+++ b/apps/backoffice-tokenization/src/app/[locale]/(dashboard)/roi/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { SectionTitle } from "@/components/shared/section-title";
+import { SectionTitle } from "@tokenization/ui/section-title";
import { RoiView } from "@/features/campaigns/components/roi/roi-view";
import { useTranslations } from "next-intl";
diff --git a/apps/backoffice-tokenization/src/app/[locale]/layout.tsx b/apps/backoffice-tokenization/src/app/[locale]/layout.tsx
index ee01c67..996354e 100644
--- a/apps/backoffice-tokenization/src/app/[locale]/layout.tsx
+++ b/apps/backoffice-tokenization/src/app/[locale]/layout.tsx
@@ -9,7 +9,7 @@ import { Toaster } from "@tokenization/ui/sonner";
import { WalletProvider } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
import type { ReactNode } from "react";
import { Inter } from "next/font/google";
-import { cn } from "@/lib/utils";
+import { cn } from "@tokenization/shared/lib/utils";
import { NextIntlClientProvider } from "next-intl";
import { SharedTranslationProvider } from "@tokenization/tw-blocks-shared/src/i18n/TranslationProvider";
import sharedEn from "@tokenization/tw-blocks-shared/src/i18n/messages/en.json";
diff --git a/apps/backoffice-tokenization/src/app/[locale]/page.tsx b/apps/backoffice-tokenization/src/app/[locale]/page.tsx
index 791fa17..746b667 100644
--- a/apps/backoffice-tokenization/src/app/[locale]/page.tsx
+++ b/apps/backoffice-tokenization/src/app/[locale]/page.tsx
@@ -1,11 +1,9 @@
import { HomeView } from "@/features/home/HomeView";
-import { Header } from "@/components/shared/Header";
export default function Home() {
return (
diff --git a/apps/backoffice-tokenization/src/components/layout/app-header.tsx b/apps/backoffice-tokenization/src/components/layout/app-header.tsx
index b7ae838..171f29f 100644
--- a/apps/backoffice-tokenization/src/components/layout/app-header.tsx
+++ b/apps/backoffice-tokenization/src/components/layout/app-header.tsx
@@ -1,14 +1,21 @@
"use client";
import { SidebarTrigger } from "@tokenization/ui/sidebar";
-import { LanguageSwitcher } from "@/components/shared/language-switcher";
+import { LanguageSwitcher } from "@tokenization/ui/language-switcher";
+import { usePathname, useRouter } from "@/i18n/navigation";
export function AppHeader() {
+ const router = useRouter();
+ const pathname = usePathname();
+
return (
);
diff --git a/apps/backoffice-tokenization/src/components/layout/app-sidebar.tsx b/apps/backoffice-tokenization/src/components/layout/app-sidebar.tsx
index 3a589ca..7b1a980 100644
--- a/apps/backoffice-tokenization/src/components/layout/app-sidebar.tsx
+++ b/apps/backoffice-tokenization/src/components/layout/app-sidebar.tsx
@@ -10,6 +10,7 @@ import {
} from "@tokenization/ui/app-sidebar";
import { SidebarWalletButton } from "@tokenization/ui/sidebar-wallet-button";
import { useTranslations } from "next-intl";
+import { usePathname } from "@/i18n/navigation";
const logo: AppSidebarLogoConfig = {
element: (
@@ -27,6 +28,7 @@ const logo: AppSidebarLogoConfig = {
export function AppSidebar() {
const t = useTranslations("nav");
+ const pathname = usePathname();
const footerItems: AppSidebarFooterItem[] = [
{
@@ -52,6 +54,7 @@ export function AppSidebar() {
return (
{
- return (
-
- );
-};
diff --git a/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx b/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx
index db9dcdd..16fd6dc 100644
--- a/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx
+++ b/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx
@@ -11,8 +11,10 @@ import { Banknote, CheckCircle, Circle, ExternalLink, Landmark } from "lucide-re
import { useGetEscrowFromIndexerByContractIds } from "@trustless-work/escrow";
import type { MultiReleaseMilestone } from "@trustless-work/escrow/types";
import type { Campaign } from "@/features/campaigns/types/campaign.types";
-import { getCampaignStatusConfig } from "@/features/campaigns/constants/campaign-status";
-import { formatCurrency } from "@/lib/utils";
+import { getCampaignStatusConfig } from "@tokenization/shared";
+import { formatCurrency } from "@tokenization/tw-blocks-shared/src/helpers/format.helper";
+import { GetEscrowsFromIndexerResponse } from "@trustless-work/escrow/types";
+import { ESCROW_EXPLORER_URL } from "@tokenization/shared/lib/constants";
interface CampaignCardProps {
campaign: Campaign;
@@ -20,22 +22,21 @@ interface CampaignCardProps {
export function CampaignCard({ campaign }: CampaignCardProps) {
const t = useTranslations("campaigns");
- const { id, name, description, status, escrowId } = campaign;
+ const { name, description, status, escrowId } = campaign;
const statusCfg = getCampaignStatusConfig(t)[status];
const isDraft = status === "DRAFT";
- const escrowExplorerUrl = `https://viewer.trustlesswork.com/${escrowId}`;
+ const escrowExplorerUrl = `${ESCROW_EXPLORER_URL}${escrowId}`;
const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds();
const { data: escrowData } = useQuery({
queryKey: ["escrow", escrowId],
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
queryFn: async () => {
const data = (await getEscrowByContractIds({
contractIds: [escrowId],
validateOnChain: true,
- })) as any;
+ })) as unknown as GetEscrowsFromIndexerResponse[];
return data?.[0] ?? null;
},
enabled: !isDraft && !!escrowId,
@@ -86,7 +87,7 @@ export function CampaignCard({ campaign }: CampaignCardProps) {
footer={
- {t("poolSize")}: USDC {formatCurrency(escrowData?.balance ?? 0)} / USDC {formatCurrency(campaign.poolSize)}
+ {t("poolSize")}: {formatCurrency(escrowData?.balance ?? 0, "USDC")} / {formatCurrency(campaign.poolSize, "USDC")}
}
diff --git a/apps/backoffice-tokenization/src/components/shared/section-title.tsx b/apps/backoffice-tokenization/src/components/shared/section-title.tsx
deleted file mode 100644
index 2889b55..0000000
--- a/apps/backoffice-tokenization/src/components/shared/section-title.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-interface SectionTitleProps {
- title: string;
- description?: string;
-}
-
-export function SectionTitle({ title, description }: SectionTitleProps) {
- return (
-
-
{title}
- {description && (
-
{description}
- )}
-
- );
-}
diff --git a/apps/backoffice-tokenization/src/components/shared/stat-item.tsx b/apps/backoffice-tokenization/src/components/shared/stat-item.tsx
deleted file mode 100644
index 6dd8bee..0000000
--- a/apps/backoffice-tokenization/src/components/shared/stat-item.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-interface StatItemProps {
- label: string;
- value: string | number;
- description?: string;
-}
-
-export function StatItem({ label, value, description }: StatItemProps) {
- return (
-
- {label}
- {value}
- {description && (
- {description}
- )}
-
- );
-}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-filter.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/campaign-filter.tsx
deleted file mode 100644
index 75afd97..0000000
--- a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-filter.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"use client";
-
-import { useTranslations } from "next-intl";
-import type { CampaignStatus } from "@/features/campaigns/types/campaign.types";
-
-interface CampaignFilterProps {
- value: CampaignStatus | "all";
- onChange: (value: CampaignStatus | "all") => void;
-}
-
-export function CampaignFilter({ value, onChange }: CampaignFilterProps) {
- const t = useTranslations("campaigns");
-
- const STATUS_OPTIONS: { value: CampaignStatus | "all"; label: string }[] = [
- { value: "all", label: t("filterAll") },
- { value: "FUNDRAISING", label: t("filterFundraising") },
- { value: "ACTIVE", label: t("filterActive") },
- { value: "CLOSED", label: t("filterClosed") },
- ];
-
- return (
-
- {STATUS_OPTIONS.map((option) => (
-
- ))}
-
- );
-}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-search.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/campaign-search.tsx
deleted file mode 100644
index 667ba1d..0000000
--- a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-search.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use client";
-
-import { useTranslations } from "next-intl";
-import { Search } from "lucide-react";
-
-interface CampaignSearchProps {
- value: string;
- onChange: (value: string) => void;
-}
-
-export function CampaignSearch({ value, onChange }: CampaignSearchProps) {
- const t = useTranslations("campaigns");
-
- return (
-
-
- onChange(e.target.value)}
- className="h-10 w-full rounded-xl border border-border bg-card pl-9 pr-4 text-sm text-foreground placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-ring"
- />
-
- );
-}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-toolbar.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/campaign-toolbar.tsx
deleted file mode 100644
index 78fd114..0000000
--- a/apps/backoffice-tokenization/src/features/campaigns/components/campaign-toolbar.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { CampaignSearch } from "./campaign-search";
-import { CampaignFilter } from "./campaign-filter";
-import type { CampaignStatus } from "@/features/campaigns/types/campaign.types";
-
-interface CampaignToolbarProps {
- onSearchChange: (value: string) => void;
- onFilterChange: (value: CampaignStatus | "all") => void;
-}
-
-export function CampaignToolbar({ onSearchChange, onFilterChange }: CampaignToolbarProps) {
- const [search, setSearch] = useState("");
- const [filter, setFilter] = useState("all");
-
- const handleSearchChange = (value: string) => {
- setSearch(value);
- onSearchChange(value);
- };
-
- const handleFilterChange = (value: CampaignStatus | "all") => {
- setFilter(value);
- onFilterChange(value);
- };
-
- return (
-
- );
-}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/campaigns-view.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/campaigns-view.tsx
deleted file mode 100644
index e9455e4..0000000
--- a/apps/backoffice-tokenization/src/features/campaigns/components/campaigns-view.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client";
-
-import { useState, useMemo } from "react";
-import { useTranslations } from "next-intl";
-import { CampaignToolbar } from "./campaign-toolbar";
-import { CampaignList } from "./campaign-list";
-import { useCampaigns } from "@/features/campaigns/hooks/use-campaigns";
-import type { CampaignStatus } from "@/features/campaigns/types/campaign.types";
-
-export function CampaignsView() {
- const t = useTranslations("campaigns");
- const [search, setSearch] = useState("");
- const [filter, setFilter] = useState("all");
- const { data: campaigns = [], isLoading, isError } = useCampaigns();
-
- const filtered = useMemo(() => {
- return campaigns.filter((c) => {
- const matchesStatus = filter === "all" || c.status === filter;
- const matchesSearch =
- search.trim() === "" ||
- c.name.toLowerCase().includes(search.toLowerCase()) ||
- (c.description ?? "").toLowerCase().includes(search.toLowerCase());
- return matchesStatus && matchesSearch;
- });
- }, [campaigns, search, filter]);
-
- if (isLoading) {
- return (
-
- {t("loading")}
-
- );
- }
-
- if (isError) {
- return (
-
- {t("loadError")}
-
- );
- }
-
- return (
-
-
-
-
- );
-}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/claim-roi-button.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/claim-roi-button.tsx
deleted file mode 100644
index f7f05b6..0000000
--- a/apps/backoffice-tokenization/src/features/campaigns/components/claim-roi-button.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-"use client";
-
-interface ClaimRoiButtonProps {
- campaignId: string;
- disabled?: boolean;
-}
-
-export function ClaimRoiButton({ campaignId, disabled }: ClaimRoiButtonProps) {
- const handleClaim = () => {
- // TODO: implement claim ROI transaction
- console.log("Claiming ROI for campaign:", campaignId);
- };
-
- return (
-
- );
-}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx
index 956a2f9..4001221 100644
--- a/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx
@@ -38,8 +38,8 @@ import {
} from "@tokenization/tw-blocks-shared/src/handle-errors/handle";
import { useEscrowContext } from "@tokenization/tw-blocks-shared/src/providers/EscrowProvider";
import { useChangeMilestoneStatus } from "@tokenization/tw-blocks-shared/src/escrows/single-multi-release/change-milestone-status/dialog/useChangeMilestoneStatus";
-import { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input";
-import { formatCurrency, fromStroops } from "@/lib/utils";
+import { numericInputKeyDown, parseNumericInput } from "@tokenization/shared/lib/utils";
+import { formatCurrency } from "@tokenization/tw-blocks-shared/src/helpers/format.helper";
import { useTranslations } from "next-intl";
interface ManageLoansViewProps {
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx
index ce43608..c4a50b9 100644
--- a/apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx
@@ -16,6 +16,8 @@ import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/
import { useUpdateRoiPercentage } from "@/features/campaigns/hooks/useUpdateRoiPercentage";
import { getRoiPercentage } from "@/features/campaigns/services/campaigns.api";
import { toast } from "sonner";
+import { useTranslations } from "next-intl";
+import { getContractExplorerUrl } from "@tokenization/shared/lib/constants";
interface UpdateRoiDialogProps {
open: boolean;
@@ -32,6 +34,7 @@ export function UpdateRoiDialog({
vaultId,
onUpdated,
}: UpdateRoiDialogProps) {
+ const t = useTranslations("roi");
const [percentage, setPercentage] = useState("");
const [isLoadingCurrent, setIsLoadingCurrent] = useState(false);
const { walletAddress } = useWalletContext();
@@ -47,7 +50,7 @@ export function UpdateRoiDialog({
const { execute, isSubmitting, error } = useUpdateRoiPercentage({
onSuccess: () => {
- toast.success("ROI percentage updated successfully");
+ toast.success(t("updateRoiDialog.successToast"));
onUpdated();
onOpenChange(false);
setPercentage("");
@@ -65,22 +68,24 @@ export function UpdateRoiDialog({
- );
-}
diff --git a/apps/investor-tokenization/src/components/ui/apple-cards-carousel.tsx b/apps/investor-tokenization/src/components/ui/apple-cards-carousel.tsx
deleted file mode 100644
index 9c5eb02..0000000
--- a/apps/investor-tokenization/src/components/ui/apple-cards-carousel.tsx
+++ /dev/null
@@ -1,520 +0,0 @@
-"use client";
-import React, {
- ReactNode,
- useCallback,
- useEffect,
- useRef,
- useState,
- createContext,
- useContext,
-} from "react";
-import {
- IconArrowNarrowLeft,
- IconArrowNarrowRight,
- IconX,
-} from "@tabler/icons-react";
-import { cn } from "@/lib/utils";
-import { AnimatePresence, motion } from "framer-motion";
-import { useOutsideClick } from "@/hooks/use-outside-click";
-import { useQuery } from "@tanstack/react-query";
-import { useGetEscrowFromIndexerByContractIds } from "@trustless-work/escrow";
-import { GetEscrowsFromIndexerResponse as Escrow } from "@trustless-work/escrow/types";
-import { RainbowButton } from "@tokenization/ui/rainbow-button";
-import { ClaimROIService } from "@/features/claim-roi/services/claim.service";
-import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
-import { toast } from "sonner";
-import { InvestDialog } from "@/features/tokens/components/InvestDialog";
-import { SelectedEscrowProvider } from "@/features/tokens/context/SelectedEscrowContext";
-import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit";
-import { SendTransactionService } from "@/lib/sendTransactionService";
-import { toastSuccessWithTx } from "@/lib/toastWithTx";
-
-interface CarouselProps {
- items: ReactNode[];
- initialScroll?: number;
- escrowIds?: string[];
- hideInvest?: boolean;
- showClaimAction?: boolean;
-}
-
-type Card = {
- escrowId: string;
- tokenSale?: string;
- tokenFactory?: string;
- vaultContractId?: string;
- campaignId?: string;
- src: string;
- content: React.ReactNode;
-};
-
-// Single escrow item type from library types
-type EscrowItem = Escrow;
-
-const NOOP = () => undefined;
-
-export const CarouselContext = createContext<{
- onCardClose: (index: number) => void;
- currentIndex: number;
- escrowsById: Record | null;
- preferBulk: boolean;
- isLoadingEscrows: boolean;
- hideInvest: boolean;
- showClaimAction: boolean;
-}>({
- onCardClose: NOOP,
- currentIndex: 0,
- escrowsById: null,
- preferBulk: false,
- isLoadingEscrows: false,
- hideInvest: false,
- showClaimAction: false,
-});
-
-export const Carousel = ({
- items,
- initialScroll = 0,
- escrowIds,
- hideInvest = false,
- showClaimAction = false,
-}: CarouselProps) => {
- const carouselRef = React.useRef(null);
- const [canScrollLeft, setCanScrollLeft] = React.useState(false);
- const [canScrollRight, setCanScrollRight] = React.useState(true);
- const [currentIndex, setCurrentIndex] = useState(0);
- const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds();
-
- // Fetch all escrows at once if a list of ids is provided
- const { data: escrowsList, isLoading: isEscrowsLoading } = useQuery<
- EscrowItem[]
- >({
- queryKey: ["escrows-by-ids", escrowIds],
- queryFn: async () => {
- const list = (await getEscrowByContractIds({
- contractIds: escrowIds ?? [],
- validateOnChain: true,
- })) as unknown as EscrowItem[];
- return Array.isArray(list) ? list : [];
- },
- enabled: Array.isArray(escrowIds) && escrowIds.length > 0,
- });
-
- const escrowsById = React.useMemo(() => {
- if (!Array.isArray(escrowIds) || !Array.isArray(escrowsList)) return null;
- const map: Record = {};
- escrowsList.forEach((item, idx) => {
- const key =
- (item as unknown as { contractId?: string })?.contractId ??
- escrowIds[idx];
- if (key) {
- map[key] = item;
- }
- });
- return map;
- }, [escrowIds, escrowsList]);
-
- const checkScrollability = useCallback(() => {
- if (carouselRef.current) {
- const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current;
- setCanScrollLeft(scrollLeft > 0);
- setCanScrollRight(scrollLeft < scrollWidth - clientWidth);
- }
- }, []);
-
- useEffect(() => {
- if (carouselRef.current) {
- carouselRef.current.scrollLeft = initialScroll;
- checkScrollability();
- }
- }, [initialScroll, checkScrollability]);
-
- const scrollLeft = () => {
- if (carouselRef.current) {
- carouselRef.current.scrollBy({ left: -300, behavior: "smooth" });
- }
- };
-
- const scrollRight = () => {
- if (carouselRef.current) {
- carouselRef.current.scrollBy({ left: 300, behavior: "smooth" });
- }
- };
-
- const handleCardClose = (index: number) => {
- if (carouselRef.current) {
- const cardWidth = isMobile() ? 230 : 384; // (md:w-96)
- const gap = isMobile() ? 4 : 8;
- const scrollPosition = (cardWidth + gap) * (index + 1);
- carouselRef.current.scrollTo({
- left: scrollPosition,
- behavior: "smooth",
- });
- setCurrentIndex(index);
- }
- };
-
- const isMobile = () => {
- return typeof window !== "undefined" && window.innerWidth < 768;
- };
-
- return (
- 0,
- isLoadingEscrows: isEscrowsLoading,
- hideInvest,
- showClaimAction,
- }}
- >
-
-
-
-
-
-
-
-
-
-
- {(() => {
- const isBulkLoading =
- Array.isArray(escrowIds) && isEscrowsLoading;
- const itemsToRender: ReactNode[] = isBulkLoading
- ? Array.from({ length: Math.max(items.length, 3) }, () => (
-
- ))
- : items;
- return itemsToRender;
- })().map((item, index) => (
-
- {item}
-
- ))}
-
-
-
-
- );
-};
-
-export const Card = ({
- card,
- index,
- layout = false,
-}: {
- card: Card;
- index: number;
- layout?: boolean;
-}) => {
- const [open, setOpen] = useState(false);
- const [isClaiming, setIsClaiming] = useState(false);
- const containerRef = useRef(null);
- const {
- onCardClose,
- escrowsById,
- preferBulk,
- isLoadingEscrows,
- hideInvest,
- showClaimAction,
- } = useContext(CarouselContext);
- const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds();
- const { walletAddress } = useWalletContext();
-
- const handleOpen = useCallback(() => {
- setOpen(true);
- }, []);
-
- const handleClose = useCallback(() => {
- setOpen(false);
- onCardClose(index);
- }, [index, onCardClose]);
-
- useEffect(() => {
- function onKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") {
- handleClose();
- }
- }
-
- if (open) {
- document.body.style.overflow = "hidden";
- } else {
- document.body.style.overflow = "auto";
- }
-
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, [open, handleClose]);
-
- const escrowFromContext = escrowsById?.[card.escrowId];
-
- const { data: fetchedEscrow } = useQuery({
- queryKey: ["escrow", card.escrowId],
- queryFn: async () => {
- const list = (await getEscrowByContractIds({
- contractIds: [card.escrowId],
- validateOnChain: true,
- })) as unknown as EscrowItem[];
- return Array.isArray(list) ? (list[0] as EscrowItem) : undefined;
- },
- // Only allow per-card fetch if we are NOT using bulk mode
- enabled:
- !preferBulk &&
- !escrowFromContext &&
- Boolean(card.escrowId) &&
- !isLoadingEscrows,
- });
-
- const escrow = escrowFromContext ?? fetchedEscrow;
-
- useOutsideClick(containerRef as React.RefObject, handleClose);
-
- const handleClaim = async () => {
- try {
- if (!card.vaultContractId) {
- toast.error("Vault contract ID not available for this card");
- return;
- }
-
- if (!walletAddress) {
- toast.error("Connect your wallet to claim");
- return;
- }
-
- setIsClaiming(true);
-
- const svc = new ClaimROIService();
- const claimResponse = await svc.claimROI({
- vaultContractId: card.vaultContractId,
- beneficiaryAddress: walletAddress,
- });
-
- if (!claimResponse?.success || !claimResponse?.xdr) {
- throw new Error(
- claimResponse?.message ?? "Failed to build claim transaction."
- );
- }
-
- const signedTxXdr = await signTransaction({
- unsignedTransaction: claimResponse.xdr ?? "",
- address: walletAddress ?? "",
- });
-
- const sender = new SendTransactionService({
- baseURL: process.env.NEXT_PUBLIC_CORE_API_URL,
- apiKey: process.env.NEXT_PUBLIC_INVESTORS_API_KEY,
- });
- const submitResponse = await sender.sendTransaction({
- signedXdr: signedTxXdr,
- });
-
- if (submitResponse.status !== "SUCCESS") {
- throw new Error(
- submitResponse.message ?? "Transaction submission failed."
- );
- }
-
- toastSuccessWithTx("ROI claimed successfully", submitResponse.hash);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Unexpected error";
- toast.error(msg);
- } finally {
- setIsClaiming(false);
- }
- };
-
- return (
- <>
-
- {open && (
-
-
-
-
-
- {!hideInvest &&
- (card.tokenSale ? (
-
-
-
- ) : (
- Invest
- ))}
-
- {showClaimAction ? (
-
- ) : null}
-
-
-
-
-
- {escrow?.title}
-
-
- {escrow?.description}
-
-
- {React.isValidElement(card.content)
- ? React.cloneElement(
- card.content as React.ReactElement
,
- {
- details: escrow,
- tokenFactory: card.tokenFactory,
- }
- )
- : card.content}
-
-
-
- )}
-
-
-
-
-
- {escrow?.title}
-
-
- {escrow?.description.slice(0, 100)}
-
-
-
-
- >
- );
-};
-
-type BlurImageProps = React.ImgHTMLAttributes & {
- src: string;
- alt?: string;
-};
-
-export const BlurImage = ({
- height,
- width,
- src,
- className,
- alt,
- ...rest
-}: BlurImageProps) => {
- return (
-
- );
-};
diff --git a/apps/investor-tokenization/src/features/claim-roi/ClaimROIView.tsx b/apps/investor-tokenization/src/features/claim-roi/ClaimROIView.tsx
deleted file mode 100644
index 8847d12..0000000
--- a/apps/investor-tokenization/src/features/claim-roi/ClaimROIView.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-"use client";
-
-import { VaultList } from "./VaultList";
-import { useTranslations } from "next-intl";
-
-export const ClaimROIView = () => {
- const t = useTranslations("claimRoi");
-
- return (
-
-
-
{t("title")}
-
- {t("description")}
-
-
-
-
-
- );
-};
diff --git a/apps/investor-tokenization/src/features/claim-roi/VaultCard.tsx b/apps/investor-tokenization/src/features/claim-roi/VaultCard.tsx
deleted file mode 100644
index e01103a..0000000
--- a/apps/investor-tokenization/src/features/claim-roi/VaultCard.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-// src/features/claim-roi/VaultCard.tsx
-"use client";
-
-import { Card } from "@/components/ui/apple-cards-carousel";
-import { useVaultInfo } from "./hooks/useVaultInfo";
-import { useTranslations } from "next-intl";
-
-export type VaultCardProps = {
- vault: {
- escrowId: string;
- tokenSale: string;
- vaultContractId: string;
- src: string;
- content?: React.ReactNode;
- };
- index: number;
-};
-
-// ! IMPORTANT: Not working
-export default function VaultCard({ vault, index }: VaultCardProps) {
- const t = useTranslations("claimRoi");
- const { vaultContractId, src } = vault;
- const { data, isLoading } = useVaultInfo(vaultContractId);
-
- return (
-
-
-
- {isLoading ? (
-
- {t("loadingVault")}
-
- ) : (
-
-
-
{t("vaultBalance")}
-
- {data?.vaultUsdcBalance ?? 0} USDC
-
-
-
-
-
{t("yourTokenBalance")}
-
- {data?.userTokenBalance ?? 0} Tokens
-
-
-
-
-
{t("yourClaimableRoi")}
-
- {data?.claimableRoi ?? 0} USDC
-
-
-
- )}
-
- ),
- }}
- index={index}
- />
- );
-}
diff --git a/apps/investor-tokenization/src/features/claim-roi/VaultList.tsx b/apps/investor-tokenization/src/features/claim-roi/VaultList.tsx
deleted file mode 100644
index f0de9de..0000000
--- a/apps/investor-tokenization/src/features/claim-roi/VaultList.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Carousel, Card } from "@/components/ui/apple-cards-carousel";
-import { DummyContent } from "@/features/transparency/Carousel";
-import VaultCard from "./VaultCard";
-
-
-const data = [
- {
- escrowId: "CCZHTYVLK6R2QMIFBTEN65ZVCSFBD3L5TXYCZJT5WTXE63ABYXBBCSEB",
- tokenSale: "CC2AGB3AW5IITDIPEZGVX6XT5RTDIVINRZL7F6KZPIHEWN2GRXL5CRCT",
- vaultContractId: "CC2AGB3AW5IITDIPEZGVX6XT5RTDIVINRZL7F6KZPIHEWN2GRXL5CRCT",
- src: "/escrows/car.png",
- content: ,
- },
- {
- escrowId: "CD775STBXITO4GNNIS7GO3KV6SYMFNBQXW536SWXOPUOO36Z64N3XBFI",
- tokenSale: "CD64ILP3SXCCY67QIVPCOVUX5Z5Q42CMKU7LK4RNAPCWD5QGBS6G7LPA",
- vaultContractId: "CBFXUH4PIGABJSRIN3GOAPH2FYL4P443UQQM3RIUV2W2EHZDNX2I4ZMA",
- src: "/escrows/car.png",
- content: ,
- },
-];
-
-export const VaultList = () => {
- const cards = data.map((vault, index) => (
-
- ));
-
- return (
-
- d.escrowId)}
- hideInvest
- showClaimAction
- />
-
- );
-};
diff --git a/apps/investor-tokenization/src/features/claim-roi/config/vaultConfig.ts b/apps/investor-tokenization/src/features/claim-roi/config/vaultConfig.ts
deleted file mode 100644
index 5e1328f..0000000
--- a/apps/investor-tokenization/src/features/claim-roi/config/vaultConfig.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-// src/features/claim-roi/config/vaultConfig.ts
-
-export const VAULT_CONFIG: Record = {
- "CAS56N75A3KYYON72ELM65FVEOV36VGLTQAOFQJIRYN7HM4L2H6TFOWD": {
- price: 50, // +50% ROI
- tokenAddress: "CBW4W4GEGD5MNXCUHGOAJ64IXLFHDMDDD65ITVM3HVIYSK22PGSHIJ5N",
- usdcAddress: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
- enabled: true,
- },
-
- "CCAPAJY44JOBHJAWVZCGAWAMK4XCST4EIZENZWI6DQHPD4RGYBKM6H7D": {
- price: 20,
- tokenAddress: "CBFTQZ3NATN6Y7PKYWGCF7OH6JOFTWUMYAJQDCBPSKGPWWQ7N23RTSNK",
- usdcAddress: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
- enabled: true,
- },
-
- "CC7RJ4EACM5HMYKIMYJ5OPYDOYING7AAHE5ACHZK3F7QSXIMIWRU5GFC": {
- price: 35,
- tokenAddress: "CBW4W4GEGD5MNXCUHGOAJ64IXLFHDMDDD65ITVM3HVIYSK22PGSHIJ5N",
- usdcAddress: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
- enabled: false, // example disabled vault
- },
-};
diff --git a/apps/investor-tokenization/src/features/claim-roi/hooks/useClaimROI.ts b/apps/investor-tokenization/src/features/claim-roi/hooks/useClaimROI.ts
new file mode 100644
index 0000000..d3380b5
--- /dev/null
+++ b/apps/investor-tokenization/src/features/claim-roi/hooks/useClaimROI.ts
@@ -0,0 +1,95 @@
+"use client";
+
+import { useState, useCallback, useMemo } from "react";
+import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
+import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit";
+import { ClaimROIService } from "@/features/claim-roi/services/claim.service";
+import { SendTransactionService } from "@tokenization/shared/lib/sendTransactionService";
+import { toastSuccessWithTx } from "@tokenization/ui/toast-with-tx";
+import { toast } from "sonner";
+
+export type ClaimROIMessages = {
+ noVault: string;
+ connectWallet: string;
+ buildFailed: string;
+ claimFailed: string;
+ success: string;
+ unexpectedError: string;
+};
+
+const DEFAULT_MESSAGES: ClaimROIMessages = {
+ noVault: "No vault available for this campaign",
+ connectWallet: "Connect your wallet to claim",
+ buildFailed: "Failed to build claim transaction",
+ claimFailed: "Claim failed",
+ success: "ROI claimed successfully",
+ unexpectedError: "Unexpected error",
+};
+
+export function useClaimROI(messages?: Partial) {
+ const { walletAddress } = useWalletContext();
+ const [isClaiming, setIsClaiming] = useState(false);
+
+ const msgs = useMemo(
+ () => ({ ...DEFAULT_MESSAGES, ...messages }),
+ [messages],
+ );
+
+ const claimROI = useCallback(
+ async (params: { vaultContractId: string }) => {
+ const { vaultContractId } = params;
+
+ if (!vaultContractId) {
+ toast.error(msgs.noVault);
+ return;
+ }
+
+ if (!walletAddress) {
+ toast.error(msgs.connectWallet);
+ return;
+ }
+
+ setIsClaiming(true);
+ try {
+ const svc = new ClaimROIService();
+ const claimResponse = await svc.claimROI({
+ vaultContractId,
+ beneficiaryAddress: walletAddress,
+ });
+
+ if (!claimResponse?.success || !claimResponse?.xdr) {
+ throw new Error(claimResponse?.message ?? msgs.buildFailed);
+ }
+
+ const signedTxXdr = await signTransaction({
+ unsignedTransaction: claimResponse.xdr,
+ address: walletAddress,
+ });
+
+ const sender = new SendTransactionService({
+ baseURL: process.env.NEXT_PUBLIC_CORE_API_URL,
+ apiKey: process.env.NEXT_PUBLIC_INVESTORS_API_KEY,
+ });
+ const submitResponse = await sender.sendTransaction({
+ signedXdr: signedTxXdr,
+ });
+
+ if (submitResponse.status !== "SUCCESS") {
+ throw new Error(
+ submitResponse.message ?? msgs.claimFailed,
+ );
+ }
+
+ toastSuccessWithTx(msgs.success, submitResponse.hash);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : msgs.unexpectedError;
+ toast.error(msg);
+ } finally {
+ setIsClaiming(false);
+ }
+ },
+ [walletAddress, msgs],
+ );
+
+ return { claimROI, isClaiming };
+}
diff --git a/apps/investor-tokenization/src/features/claim-roi/hooks/useVaultInfo.ts b/apps/investor-tokenization/src/features/claim-roi/hooks/useVaultInfo.ts
deleted file mode 100644
index 2effb58..0000000
--- a/apps/investor-tokenization/src/features/claim-roi/hooks/useVaultInfo.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-// src/features/claim-roi/hooks/useVaultInfo.ts
-"use client";
-
-import { useQuery } from "@tanstack/react-query";
-import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
-import { getVaultInfo } from "../services/vaultInfo.service";
-
-export const useVaultInfo = (vaultContractId: string) => {
- // 🔥 Get the real wallet address from WalletContext
- const { walletAddress } = useWalletContext();
-
- return useQuery({
- queryKey: ["vault-info", vaultContractId, walletAddress],
- queryFn: () => getVaultInfo(vaultContractId, walletAddress!),
- enabled: !!walletAddress && !!vaultContractId,
- });
-};
diff --git a/apps/investor-tokenization/src/features/claim-roi/services/getTokenBalance.ts b/apps/investor-tokenization/src/features/claim-roi/services/getTokenBalance.ts
deleted file mode 100644
index 0a5dc88..0000000
--- a/apps/investor-tokenization/src/features/claim-roi/services/getTokenBalance.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-// src/features/claim-roi/services/getTokenBalance.ts
-import {
- Contract,
- rpc,
- scValToNative,
- Address,
- TransactionBuilder,
-} from "@stellar/stellar-sdk";
-
-const server = new rpc.Server(
- process.env.NEXT_PUBLIC_SOROBAN_RPC_URL!,
- { allowHttp: true }
-);
-
-export async function getTokenBalance(
- tokenContractId: string,
- holderAddress: string,
- simulationSource: string // NEW: must be a real account
-): Promise {
- try {
- // ⭐ Use the REAL account (wallet) to simulate
- const source = await server.getAccount(simulationSource);
-
- const contract = new Contract(tokenContractId);
-
- // Build a tx calling balance()
- const tx = new TransactionBuilder(source, {
- networkPassphrase: process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE!,
- fee: "100",
- })
- .addOperation(
- contract.call(
- "balance",
- new Address(holderAddress).toScVal()
- )
- )
- .setTimeout(30)
- .build();
-
- // Simulate
- const sim: any = await server.simulateTransaction(tx);
-
- // Handle ALL possible SDK versions
- const retval =
- sim?.result?.retval ||
- sim?.results?.[0]?.retval ||
- undefined;
-
- if (!retval) return 0;
-
- const raw = scValToNative(retval);
-
- return Number(raw) / 1_0000000;
-
- } catch (error) {
- console.error("Token balance RPC error:", error);
- return 0;
- }
-}
diff --git a/apps/investor-tokenization/src/features/claim-roi/services/vaultInfo.service.ts b/apps/investor-tokenization/src/features/claim-roi/services/vaultInfo.service.ts
deleted file mode 100644
index b01b3ad..0000000
--- a/apps/investor-tokenization/src/features/claim-roi/services/vaultInfo.service.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// src/features/claim-roi/services/vaultInfo.service.ts
-import { getTokenBalance } from "./getTokenBalance";
-import { VAULT_CONFIG } from "../config/vaultConfig";
-
-export async function getVaultInfo(vaultId: string, userAddress: string) {
- const config = VAULT_CONFIG[vaultId];
- if (!config) throw new Error(`Vault not found: ${vaultId}`);
-
- const { price, tokenAddress, usdcAddress, enabled } = config;
-
- // 1) Vault USDC balance
- const vaultUsdcBalance = await getTokenBalance(
- usdcAddress, // USDC token contract
- vaultId, // vault contract address (C…)
- userAddress // real user address for simulation
- );
-
- // 2) User participation token balance
- const userTokenBalance = await getTokenBalance(
- tokenAddress, // participation token contract
- userAddress, // user account
- userAddress // real user address
- );
-
- // 3) ROI formula
- const claimableRoi =
- userTokenBalance > 0 ? (userTokenBalance * (100 + price)) / 100 : 0;
-
- return {
- enabled,
- price,
- tokenAddress,
- usdcAddress,
- vaultUsdcBalance,
- userTokenBalance,
- claimableRoi,
- };
-}
diff --git a/apps/investor-tokenization/src/features/investments/InvestmentsView.tsx b/apps/investor-tokenization/src/features/investments/InvestmentsView.tsx
deleted file mode 100644
index 4afc42b..0000000
--- a/apps/investor-tokenization/src/features/investments/InvestmentsView.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { useUserInvestments } from "./hooks/useUserInvestments.hook";
-import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
-import { InvestmentCard } from "./components/InvestmentCard";
-import { Card } from "@tokenization/ui/card";
-import { Button } from "@tokenization/ui/button";
-import { Wallet, TrendingUp, DollarSign } from "lucide-react";
-import { useWallet } from "@tokenization/tw-blocks-shared/src/wallet-kit/useWallet";
-import { useTranslations } from "next-intl";
-
-export const InvestmentsView = () => {
- const t = useTranslations("investments");
- const tCommon = useTranslations("common");
- const { walletAddress } = useWalletContext();
- const { handleConnect } = useWallet();
-
- const {
- data: investments,
- isLoading,
- isError,
- error,
- } = useUserInvestments();
-
- if (!walletAddress) {
- return (
-
-
-
{t("title")}
-
- {t("connectDescription")}
-
-
-
-
- );
- }
-
- if (isLoading) {
- return (
-
-
-
{t("title")}
-
{t("loadingYourInvestments")}
-
-
- );
- }
-
- if (isError) {
- return (
-
-
-
{t("title")}
-
- {t("loadError", { message: error instanceof Error ? error.message : tCommon("unknownError") })}
-
-
-
- );
- }
-
- const investmentList = investments ?? [];
-
- const totalInvested = React.useMemo(() => {
- return investmentList.reduce((sum, inv) => sum + Number(inv.usdcAmount), 0);
- }, [investmentList]);
-
- const uniqueCampaigns = React.useMemo(() => {
- return new Set(investmentList.map((inv) => inv.campaignId)).size;
- }, [investmentList]);
-
- return (
-
-
-
-
{t("title")}
-
- {t("description")}
-
-
-
-
-
-
-
-
-
-
-
{t("totalInvestments")}
-
{investmentList.length}
-
-
-
-
-
-
-
-
-
-
-
{t("campaignsLabel")}
-
{uniqueCampaigns}
-
-
-
-
-
-
-
-
-
-
-
{t("totalInvested")}
-
- {totalInvested.toLocaleString("en-US", {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}{" "}
- USDC
-
-
-
-
-
-
- {investmentList.length === 0 ? (
-
-
- {t("noInvestmentsTitle")}
-
- {t("noInvestmentsDesc")}
-
-
- ) : (
-
- {investmentList.map((investment) => (
-
- ))}
-
- )}
-
-
- );
-};
diff --git a/apps/investor-tokenization/src/features/investments/components/InvestmentCard.tsx b/apps/investor-tokenization/src/features/investments/components/InvestmentCard.tsx
deleted file mode 100644
index ff2c475..0000000
--- a/apps/investor-tokenization/src/features/investments/components/InvestmentCard.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-"use client";
-
-import { Card, CardContent, CardHeader, CardTitle } from "@tokenization/ui/card";
-import { Badge } from "@tokenization/ui/badge";
-import {
- formatAddress,
-} from "@tokenization/tw-blocks-shared/src/helpers/format.helper";
-import { Calendar, DollarSign, ExternalLink, Coins } from "lucide-react";
-import { Link } from "@/i18n/navigation";
-import type { InvestmentFromApi } from "../services/investment.service";
-import { useTranslations } from "next-intl";
-import { getCampaignStatusConfig } from "@/features/roi/constants/campaign-status";
-import type { CampaignStatus } from "@/features/roi/types/campaign.types";
-
-type InvestmentCardProps = {
- investment: InvestmentFromApi;
-};
-
-const STATUS_VARIANT: Record = {
- ACTIVE: "default",
- FUNDRAISING: "default",
- REPAYMENT: "secondary",
- CLAIMABLE: "secondary",
- CLOSED: "outline",
- PAUSED: "destructive",
- DRAFT: "outline",
-};
-
-export const InvestmentCard = ({ investment }: InvestmentCardProps) => {
- const t = useTranslations("investments");
- const tCampaigns = useTranslations("campaigns");
- const { campaign } = investment;
- const usdcAmount = Number(investment.usdcAmount);
- const tokenAmount = Number(investment.tokenAmount);
- const createdAt = new Date(investment.createdAt);
- const statusConfig = getCampaignStatusConfig(tCampaigns);
- const statusLabel = statusConfig[campaign.status as CampaignStatus]?.label ?? campaign.status;
-
- return (
-
-
-
-
- {campaign.name}
-
-
- {statusLabel}
-
-
- {campaign.description && (
-
- {campaign.description}
-
- )}
-
-
-
-
-
-
-
-
- {t("invested")}
-
-
-
- {usdcAmount.toLocaleString("en-US", {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}
-
-
USDC
-
-
-
-
-
-
- {t("tokens")}
-
-
-
- {tokenAmount.toLocaleString("en-US", {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}
-
-
{t("received")}
-
-
-
- {campaign.expectedReturn > 0 && (
-
-
- {t("expectedReturn")}
-
- {Number(campaign.expectedReturn)}%
-
-
- {campaign.loanDuration > 0 && (
-
- {t("duration")}
-
- {t("durationMonths", { count: campaign.loanDuration })}
-
-
- )}
-
- )}
-
-
-
-
-
- {t("investedOn")}
-
-
- {createdAt.toLocaleDateString("en-US", {
- year: "numeric",
- month: "short",
- day: "numeric",
- })}
-
-
-
-
- {t("txHash")}
-
- {formatAddress(investment.txHash)}
-
-
-
-
- {campaign.escrowId && (
-
- {t("escrow")}
-
- {formatAddress(campaign.escrowId)}
-
-
-
- )}
-
-
-
- );
-};
diff --git a/apps/investor-tokenization/src/features/investments/hooks/useProjectTokenBalances.hook.ts b/apps/investor-tokenization/src/features/investments/hooks/useProjectTokenBalances.hook.ts
deleted file mode 100644
index 9ef4f39..0000000
--- a/apps/investor-tokenization/src/features/investments/hooks/useProjectTokenBalances.hook.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
-import { InvestmentService } from "../services/investment.service";
-
-export type ProjectTokenBalanceInfo = {
- escrowId: string;
- tokenFactory: string;
- balance: string;
- tokenName?: string;
- tokenSymbol?: string;
- tokenDecimals?: number;
-};
-
-// Project data from ProjectList - includes tokenFactory addresses
-const PROJECT_DATA = [
- {
- escrowId: "CCZHTYVLK6R2QMIFBTEN65ZVCSFBD3L5TXYCZJT5WTXE63ABYXBBCSEB",
- tokenSale: "CC2AGB3AW5IITDIPEZGVX6XT5RTDIVINRZL7F6KZPIHEWN2GRXL5CRCT",
- tokenFactory: "CDJTII2GR2FY6Q4NDJGZI7NW2SHQ7GR5Y2H7B7Q253PTZZAZZ25TFYYU",
- },
- {
- escrowId: "CD775STBXITO4GNNIS7GO3KV6SYMFNBQXW536SWXOPUOO36Z64N3XBFI",
- tokenFactory: "CCEFVY5PCQEO7KSNS52DUXOFXU7PXMQ4GPT25S5ZDYZQPWA74XXY5Y5N",
- tokenSale: "CD64ILP3SXCCY67QIVPCOVUX5Z5Q42CMKU7LK4RNAPCWD5QGBS6G7LPA",
- },
-];
-
-/**
- * Hook to fetch token balances and metadata for all projects
- * Returns a map of escrowId -> token balance info
- */
-export const useProjectTokenBalances = () => {
- const { walletAddress } = useWalletContext();
- const investmentService = new InvestmentService();
-
- return useQuery>({
- queryKey: ["project-token-balances", walletAddress],
- queryFn: async () => {
- if (!walletAddress) {
- return {};
- }
-
- // Check token balances and metadata for all projects in parallel
- const balanceChecks = await Promise.allSettled(
- PROJECT_DATA.map(async (project) => {
- try {
- const [balanceResponse, metadataResponse] = await Promise.all([
- investmentService.getTokenBalance({
- tokenFactoryAddress: project.tokenFactory,
- address: walletAddress,
- }),
- investmentService.getTokenMetadata({
- tokenFactoryAddress: project.tokenFactory,
- }).catch(() => ({
- success: false,
- name: undefined,
- symbol: undefined,
- decimals: 7,
- })),
- ]);
-
- const balance = balanceResponse.success
- ? balanceResponse.balance || "0"
- : "0";
-
- return {
- escrowId: project.escrowId,
- tokenFactory: project.tokenFactory,
- balance,
- tokenName: metadataResponse.success ? metadataResponse.name : undefined,
- tokenSymbol: metadataResponse.success ? metadataResponse.symbol : undefined,
- tokenDecimals: metadataResponse.success ? metadataResponse.decimals : 7,
- };
- } catch (error) {
- console.warn(
- `Failed to check balance for project ${project.escrowId}:`,
- error,
- );
- return {
- escrowId: project.escrowId,
- tokenFactory: project.tokenFactory,
- balance: "0",
- tokenName: undefined,
- tokenSymbol: undefined,
- tokenDecimals: 7,
- };
- }
- }),
- );
-
- // Build a map of escrowId -> balance info
- const balancesMap: Record = {};
- balanceChecks.forEach((result) => {
- if (result.status === "fulfilled") {
- balancesMap[result.value.escrowId] = result.value;
- }
- });
-
- return balancesMap;
- },
- enabled: Boolean(walletAddress),
- staleTime: 1000 * 60 * 2, // 2 minutes
- });
-};
-
diff --git a/apps/investor-tokenization/src/features/roi/components/campaign-filter.tsx b/apps/investor-tokenization/src/features/roi/components/campaign-filter.tsx
deleted file mode 100644
index 171d4cc..0000000
--- a/apps/investor-tokenization/src/features/roi/components/campaign-filter.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-"use client";
-
-import { useTranslations } from "next-intl";
-import type { CampaignStatus } from "../types/campaign.types";
-
-interface CampaignFilterProps {
- value: CampaignStatus | "all";
- onChange: (value: CampaignStatus | "all") => void;
-}
-
-export function CampaignFilter({ value, onChange }: CampaignFilterProps) {
- const t = useTranslations("campaigns");
-
- const STATUS_OPTIONS: { value: CampaignStatus | "all"; labelKey: string }[] = [
- { value: "all", labelKey: "filterAll" },
- { value: "FUNDRAISING", labelKey: "filterFundraising" },
- { value: "ACTIVE", labelKey: "filterActive" },
- { value: "REPAYMENT", labelKey: "filterRepayment" },
- { value: "CLAIMABLE", labelKey: "filterClaimable" },
- { value: "CLOSED", labelKey: "filterClosed" },
- ];
-
- return (
-
- {STATUS_OPTIONS.map((option) => (
-
- ))}
-
- );
-}
diff --git a/apps/investor-tokenization/src/features/roi/components/campaign-list.tsx b/apps/investor-tokenization/src/features/roi/components/campaign-list.tsx
index ab1dd35..a1c6c4a 100644
--- a/apps/investor-tokenization/src/features/roi/components/campaign-list.tsx
+++ b/apps/investor-tokenization/src/features/roi/components/campaign-list.tsx
@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl";
import type { Campaign } from "../types/campaign.types";
-import { CampaignCard } from "./campaign-card";
+import { CampaignCard } from "@/components/shared/campaign-card";
type CampaignListProps = {
campaigns: Campaign[];
diff --git a/apps/investor-tokenization/src/features/roi/components/campaign-search.tsx b/apps/investor-tokenization/src/features/roi/components/campaign-search.tsx
deleted file mode 100644
index 58a144a..0000000
--- a/apps/investor-tokenization/src/features/roi/components/campaign-search.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use client";
-
-import { Search } from "lucide-react";
-import { useTranslations } from "next-intl";
-
-interface CampaignSearchProps {
- value: string;
- onChange: (value: string) => void;
-}
-
-export function CampaignSearch({ value, onChange }: CampaignSearchProps) {
- const t = useTranslations("campaigns");
-
- return (
-
-
- onChange(e.target.value)}
- className="h-10 w-full rounded-xl border border-border bg-card pl-9 pr-4 text-sm text-foreground placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-ring"
- />
-
- );
-}
diff --git a/apps/investor-tokenization/src/features/roi/components/campaign-toolbar.tsx b/apps/investor-tokenization/src/features/roi/components/campaign-toolbar.tsx
deleted file mode 100644
index 9cda0a2..0000000
--- a/apps/investor-tokenization/src/features/roi/components/campaign-toolbar.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { CampaignSearch } from "./campaign-search";
-import { CampaignFilter } from "./campaign-filter";
-import type { CampaignStatus } from "../types/campaign.types";
-
-interface CampaignToolbarProps {
- onSearchChange: (value: string) => void;
- onFilterChange: (value: CampaignStatus | "all") => void;
-}
-
-export function CampaignToolbar({ onSearchChange, onFilterChange }: CampaignToolbarProps) {
- const [search, setSearch] = useState("");
- const [filter, setFilter] = useState("all");
-
- const handleSearchChange = (value: string) => {
- setSearch(value);
- onSearchChange(value);
- };
-
- const handleFilterChange = (value: CampaignStatus | "all") => {
- setFilter(value);
- onFilterChange(value);
- };
-
- return (
-
- );
-}
diff --git a/apps/investor-tokenization/src/features/roi/components/investor-profile-card.tsx b/apps/investor-tokenization/src/features/roi/components/investor-profile-card.tsx
deleted file mode 100644
index 802f4bf..0000000
--- a/apps/investor-tokenization/src/features/roi/components/investor-profile-card.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Avatar, AvatarFallback, AvatarImage } from "@tokenization/ui/avatar";
-import { cn } from "@/lib/utils";
-
-type InvestorProfileCardProps = {
- name?: string;
- avatarUrl?: string | null;
- label?: string;
- className?: string;
-};
-
-const defaultName = "Investor";
-const defaultLabel = "Investor";
-
-export function InvestorProfileCard({
- name = defaultName,
- avatarUrl,
- label = defaultLabel,
- className,
-}: InvestorProfileCardProps) {
- const initials = name
- .split(" ")
- .map((n) => n[0])
- .join("")
- .toUpperCase()
- .slice(0, 2);
-
- return (
-
-
- {avatarUrl ? (
-
- ) : null}
-
- {initials}
-
-
-
-
- );
-}
diff --git a/apps/investor-tokenization/src/features/roi/constants/campaign-status.ts b/apps/investor-tokenization/src/features/roi/constants/campaign-status.ts
deleted file mode 100644
index 72890a4..0000000
--- a/apps/investor-tokenization/src/features/roi/constants/campaign-status.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { CampaignStatus } from "../types/campaign.types";
-
-type StatusConfig = { label: string; className: string };
-
-type TranslationFn = (key: string) => string;
-
-export function getCampaignStatusConfig(
- t: TranslationFn,
-): Record {
- return {
- DRAFT: { label: t("status.DRAFT"), className: "bg-secondary text-text-muted border-border" },
- FUNDRAISING: { label: t("status.FUNDRAISING"), className: "bg-yellow-50 text-yellow-700 border-yellow-200" },
- ACTIVE: { label: t("status.ACTIVE"), className: "bg-success-bg text-success border-success/30" },
- REPAYMENT: { label: t("status.REPAYMENT"), className: "bg-blue-50 text-blue-700 border-blue-200" },
- CLAIMABLE: { label: t("status.CLAIMABLE"), className: "bg-purple-50 text-purple-700 border-purple-200" },
- CLOSED: { label: t("status.CLOSED"), className: "bg-secondary text-text-muted border-border" },
- PAUSED: { label: t("status.PAUSED"), className: "bg-orange-50 text-orange-700 border-orange-200" },
- };
-}
-
-/**
- * @deprecated Use getCampaignStatusConfig(t) instead for i18n support.
- */
-export const CAMPAIGN_STATUS_CONFIG: Record = {
- DRAFT: { label: "Draft", className: "bg-secondary text-text-muted border-border" },
- FUNDRAISING: { label: "Fundraising", className: "bg-yellow-50 text-yellow-700 border-yellow-200" },
- ACTIVE: { label: "Active", className: "bg-success-bg text-success border-success/30" },
- REPAYMENT: { label: "Repayment", className: "bg-blue-50 text-blue-700 border-blue-200" },
- CLAIMABLE: { label: "Claimable", className: "bg-purple-50 text-purple-700 border-purple-200" },
- CLOSED: { label: "Closed", className: "bg-secondary text-text-muted border-border" },
- PAUSED: { label: "Paused", className: "bg-orange-50 text-orange-700 border-orange-200" },
-};
diff --git a/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx b/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx
index b9a9e28..5974283 100644
--- a/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx
+++ b/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx
@@ -24,14 +24,6 @@ import {
type BuyTokenPayload,
} from "@/features/tokens/services/token.service";
import { addToken } from "@stellar/freighter-api";
-
-const SOROBAN_RPC_URL =
- process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ??
- "https://soroban-testnet.stellar.org";
-const NETWORK_PASSPHRASE =
- process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ??
- "Test SDF Network ; September 2015";
-
import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit";
import { useSelectedEscrow } from "@/features/tokens/context/SelectedEscrowContext";
@@ -39,9 +31,10 @@ import { createInvestment } from "@/features/investments/services/investment.ser
import { MultiReleaseMilestone } from "@trustless-work/escrow";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
-import { fromStroops } from "@/utils/adjustedAmounts";
+import { fromStroops } from "@tokenization/shared/lib/utils";
import { Networks, rpc, TransactionBuilder } from "@stellar/stellar-sdk";
import { useTranslations } from "next-intl";
+import { USDC_ADDRESS, SOROBAN_RPC_URL, NETWORK_PASSPHRASE } from "@tokenization/shared/lib/constants";
type InvestFormValues = {
amount: number;
@@ -54,8 +47,6 @@ interface InvestDialogProps {
loanDuration?: number;
}
-const DEFAULT_USDC_ADDRESS = process.env.NEXT_PUBLIC_DEFAULT_USDC_ADDRESS ?? "";
-
export function InvestDialog({
tokenSaleContractId,
triggerLabel,
@@ -112,7 +103,7 @@ export function InvestDialog({
if (addTokenResult.error) {
throw new Error(
- addTokenResult.error ?? "Failed to add token to Freighter.",
+ addTokenResult.error ?? t("errors.failedAddToken"),
);
}
}
@@ -121,7 +112,7 @@ export function InvestDialog({
setSubmitStep("buy");
const payload: BuyTokenPayload = {
tokenSaleContractId,
- usdcAddress: DEFAULT_USDC_ADDRESS,
+ usdcAddress: USDC_ADDRESS,
payerAddress: walletAddress,
beneficiaryAddress: walletAddress,
amount: values.amount,
@@ -145,7 +136,7 @@ export function InvestDialog({
const send = await server.sendTransaction(tx);
if (send.status === "ERROR") {
throw new Error(
- `Soroban error: ${JSON.stringify(send.errorResult)}`,
+ `${t("errors.sorobanErrorPrefix")}: ${JSON.stringify(send.errorResult)}`,
);
}
@@ -183,18 +174,20 @@ export function InvestDialog({
form.reset({ amount: 0 });
setOpen(false);
} catch (err) {
- let message =
- err instanceof Error
- ? err.message
- : t("errors.unexpectedError");
+ const rawMessage = err instanceof Error ? err.message : "";
+ let message = t("errors.unexpectedError");
// Check if error is due to insufficient USDC balance
if (
- message.includes("resulting balance is not within the allowed range") ||
- message.includes("balance is not within") ||
- message.includes("insufficient balance")
+ rawMessage.includes("resulting balance is not within the allowed range") ||
+ rawMessage.includes("balance is not within") ||
+ rawMessage.includes("insufficient balance")
) {
message = t("errors.insufficientBalance");
+ } else if (rawMessage.includes(t("errors.failedAddToken"))) {
+ message = t("errors.failedAddToken");
+ } else if (rawMessage.includes(t("errors.failedBuild"))) {
+ message = t("errors.failedBuild");
}
setErrorMessage(message);
@@ -346,7 +339,8 @@ export function InvestDialog({
- Disclaimer: {t("disclaimer")}
+ {t("disclaimerLabel")}:{" "}
+ {t("disclaimer")}
@@ -363,10 +357,6 @@ export function InvestDialog({
>
{getSubmitButtonText()}
-
- {/*
- {t("termsAgreement")}
-
*/}
diff --git a/apps/investor-tokenization/src/features/transparency/CampaignsGrid.tsx b/apps/investor-tokenization/src/features/transparency/CampaignsGrid.tsx
new file mode 100644
index 0000000..631ec6f
--- /dev/null
+++ b/apps/investor-tokenization/src/features/transparency/CampaignsGrid.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useMemo } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useGetEscrowFromIndexerByContractIds } from "@trustless-work/escrow";
+import type { GetEscrowsFromIndexerResponse } from "@trustless-work/escrow/types";
+import { ProjectCard } from "./ProjectCard";
+import type { CampaignFromApi } from "./types";
+
+interface CampaignsGridProps {
+ campaigns: CampaignFromApi[];
+ isLoading?: boolean;
+}
+
+export function CampaignsGrid({ campaigns, isLoading }: CampaignsGridProps) {
+ const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds();
+
+ const escrowIds = useMemo(
+ () => campaigns.map((c) => c.escrowId).filter(Boolean),
+ [campaigns],
+ );
+
+ const { data: escrowsList, isLoading: isEscrowsLoading } = useQuery({
+ queryKey: ["escrows-by-ids", escrowIds],
+ queryFn: async () => {
+ const result = await getEscrowByContractIds({
+ contractIds: escrowIds,
+ validateOnChain: false,
+ });
+ const list = Array.isArray(result)
+ ? result
+ : result
+ ? [result]
+ : [];
+ return list as GetEscrowsFromIndexerResponse[];
+ },
+ enabled: escrowIds.length > 0,
+ staleTime: 1000 * 60 * 10,
+ });
+
+ const escrowsById = useMemo(() => {
+ if (!escrowsList || !Array.isArray(escrowsList)) return {};
+ return escrowsList.reduce(
+ (acc, item, idx) => {
+ const key =
+ (item as { contractId?: string })?.contractId ?? escrowIds[idx];
+ if (key) acc[key] = item;
+ return acc;
+ },
+ {} as Record,
+ );
+ }, [escrowsList, escrowIds]);
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {campaigns.map((campaign) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/investor-tokenization/src/features/transparency/Carousel.tsx b/apps/investor-tokenization/src/features/transparency/Carousel.tsx
deleted file mode 100644
index 0240298..0000000
--- a/apps/investor-tokenization/src/features/transparency/Carousel.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-"use client";
-
-import { BalanceProgressBar } from "@tokenization/tw-blocks-shared/src/escrows/indicators/balance-progress/bar/BalanceProgress";
-import { Button } from "@tokenization/ui/button";
-import {
- formatCurrency,
- formatTimestamp,
-} from "@tokenization/tw-blocks-shared/src/helpers/format.helper";
-import type {
- GetEscrowsFromIndexerResponse as Escrow,
- MultiReleaseMilestone,
-} from "@trustless-work/escrow/types";
-import Link from "next/link";
-import { useProjectTokenBalances } from "@/features/investments/hooks/useProjectTokenBalances.hook";
-import { Wallet, ExternalLink } from "lucide-react";
-import { fromStroops } from "@/utils/adjustedAmounts";
-
-export const DummyContent = ({
- details,
- tokenFactory,
-}: {
- details?: Escrow;
- tokenFactory?: string;
-}) => {
- const { data: tokenBalances } = useProjectTokenBalances();
- const escrowId = details?.contractId;
- const tokenBalanceInfo = escrowId ? tokenBalances?.[escrowId] : undefined;
- const rawBalance = parseFloat(tokenBalanceInfo?.balance || "0");
- const tokenDecimals = tokenBalanceInfo?.tokenDecimals || 7;
- const formattedBalance = rawBalance / Math.pow(10, tokenDecimals);
- const tokenSymbol = tokenBalanceInfo?.tokenSymbol || "TOKEN";
- const tokenName = tokenBalanceInfo?.tokenName;
- const milestones = (details?.milestones || []) as MultiReleaseMilestone[];
-
- const totalAmount = milestones.reduce(
- (acc, milestone) => acc + fromStroops(milestone.amount ?? 0),
- 0
- );
-
- return (
-
- {/* User Token Balance - Stellar Expert Style Display */}
- {formattedBalance > 0 && (
-
-
-
-
-
-
-
-
- {tokenName ? `${tokenName} Balance` : "Your Investment"}
-
- {tokenSymbol && (
-
- {tokenSymbol}
-
- )}
-
-
- {tokenBalanceInfo?.tokenFactory && (
-
-
-
- )}
-
-
-
- {formattedBalance.toLocaleString(undefined, {
- minimumFractionDigits: 2,
- maximumFractionDigits: tokenDecimals,
- })}
-
- {tokenSymbol && (
-
- {tokenSymbol}
-
- )}
-
- {tokenBalanceInfo?.tokenFactory && (
-
-
- Raw: {rawBalance.toLocaleString()}
-
-
- View on Stellar Expert
-
-
-
- )}
-
- )}
-
- {/* Info Grid */}
-
- {totalAmount !== undefined && (
-
-
- Total Amount
-
-
- {formatCurrency(totalAmount, details?.trustline?.symbol ?? "USDC")}
-
-
- )}
-
- {details?.balance !== undefined && (
-
-
- Current Balance
-
-
- {formatCurrency(details.balance, details?.trustline?.symbol ?? "USDC")}
-
-
- )}
-
-
-
-
- {/* Metadata */}
- {details?.createdAt && (
-
-
- Created: {formatTimestamp(details.createdAt)}
-
-
- )}
-
- {details?.contractId && (
-
-
- Contract ID: {details.contractId}
-
-
- )}
-
- {tokenFactory && (
-
-
- Token Address: {tokenFactory}
-
-
-
-
-
-
-
- )}
-
- {/* Milestones Section - EMPHASIZED */}
- {milestones.length > 0 && (
-
-
- Milestones
-
-
-
- {milestones.map((milestone, index) => (
-
-
-
-
- {index + 1}
-
-
- {milestone.description}
-
-
- {milestone.status && (
-
- {milestone.status}
-
- )}
-
-
-
- {milestone.amount !== undefined && (
-
-
- Amount
-
-
- {fromStroops(milestone.amount ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
-
-
- )}
-
- {milestone.receiver && (
-
-
- Receiver
-
-
- {milestone.receiver}
-
-
- )}
-
-
- {milestone.evidence && (
-
-
- Evidence
-
-
- {milestone.evidence}
-
-
- )}
-
- ))}
-
-
- )}
-
- );
-};
diff --git a/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx b/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx
index 247480f..6778556 100644
--- a/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx
+++ b/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx
@@ -12,9 +12,9 @@ import type {
} from "@trustless-work/escrow/types";
import { InvestDialog } from "@/features/tokens/components/InvestDialog";
import { SelectedEscrowProvider } from "@/features/tokens/context/SelectedEscrowContext";
-import { getCampaignStatusConfig } from "@/features/roi/constants/campaign-status";
+import { getCampaignStatusConfig } from "@tokenization/shared";
import type { CampaignFromApi } from "./types";
-import { fromStroops } from "@/utils/adjustedAmounts";
+import { fromStroops } from "@tokenization/shared/lib/utils";
import { useTranslations } from "next-intl";
export type ProjectCardProps = {
diff --git a/apps/investor-tokenization/src/hooks/use-outside-click.tsx b/apps/investor-tokenization/src/hooks/use-outside-click.tsx
deleted file mode 100644
index 8121912..0000000
--- a/apps/investor-tokenization/src/hooks/use-outside-click.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React, { useEffect } from "react";
-
-export const useOutsideClick = (
- ref: React.RefObject,
- callback: (event: MouseEvent | TouchEvent) => void,
-) => {
- useEffect(() => {
- const listener = (event: MouseEvent | TouchEvent) => {
- const target = event.target as Element | null;
-
- // Ignore clicks occurring inside any open dialog rendered via portal
- // (e.g., Radix/shadcn Dialog), to avoid unintended outside-close.
- if (target && target.closest('[role="dialog"]')) {
- return;
- }
-
- // DO NOTHING if the element being clicked is the target element or their children
- if (!ref.current || (target && ref.current.contains(target))) {
- return;
- }
- callback(event);
- };
-
- document.addEventListener("mousedown", listener);
- document.addEventListener("touchstart", listener);
-
- return () => {
- document.removeEventListener("mousedown", listener);
- document.removeEventListener("touchstart", listener);
- };
- }, [ref, callback]);
-};
diff --git a/apps/investor-tokenization/src/lib/contractErrorHandler.ts b/apps/investor-tokenization/src/lib/contractErrorHandler.ts
deleted file mode 100644
index c96101b..0000000
--- a/apps/investor-tokenization/src/lib/contractErrorHandler.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Vault Contract Error Codes
- * These match the ContractError enum in the vault Rust smart contract
- */
-export enum VaultContractError {
- AdminNotFound = 1,
- OnlyAdminCanChangeAvailability = 2,
- ExchangeIsCurrentlyDisabled = 3,
- BeneficiaryHasNoTokensToClaim = 4,
- VaultDoesNotHaveEnoughUSDC = 5,
-}
-
-/**
- * Token Sale Contract Error Codes
- * These match the ContractError enum in the token-sale Rust smart contract
- */
-export enum TokenSaleContractError {
- EscrowContractNotFound = 1,
- ParticipationTokenNotFound = 2,
- AdminNotFound = 3,
- OnlyAdminCanSetToken = 4,
- HardCapExceeded = 5,
- InvestorCapExceeded = 6,
- AmountMustBePositive = 7,
-}
-
-const VAULT_ERROR_MESSAGES: Record = {
- [VaultContractError.AdminNotFound]: "Admin not found",
- [VaultContractError.OnlyAdminCanChangeAvailability]:
- "Only admin can change availability",
- [VaultContractError.ExchangeIsCurrentlyDisabled]:
- "Exchange is currently disabled",
- [VaultContractError.BeneficiaryHasNoTokensToClaim]:
- "Beneficiary has no tokens to claim",
- [VaultContractError.VaultDoesNotHaveEnoughUSDC]:
- "Vault does not have enough USDC",
-};
-
-const TOKEN_SALE_ERROR_MESSAGES: Record = {
- [TokenSaleContractError.EscrowContractNotFound]:
- "Escrow contract not found",
- [TokenSaleContractError.ParticipationTokenNotFound]:
- "Participation token not found",
- [TokenSaleContractError.AdminNotFound]: "Admin not found",
- [TokenSaleContractError.OnlyAdminCanSetToken]:
- "Only admin can set token",
- [TokenSaleContractError.HardCapExceeded]:
- "Hard cap exceeded – the campaign is fully funded",
- [TokenSaleContractError.InvestorCapExceeded]:
- "Investor cap exceeded – you have reached the maximum investment",
- [TokenSaleContractError.AmountMustBePositive]:
- "Amount must be positive",
-};
-
-const ERROR_MESSAGES_BY_CONTEXT: Record<
- string,
- Record
-> = {
- vault: VAULT_ERROR_MESSAGES,
- "token-sale": TOKEN_SALE_ERROR_MESSAGES,
-};
-
-type TranslationFn = (key: string, values?: Record) => string;
-
-/**
- * Extracts and maps contract error codes to user-friendly messages.
- *
- * When a translation function `t` is provided (from `useTranslations("contractErrors")`),
- * it will use i18n keys like `t("vault.1")` or `t("tokenSale.5")`.
- * Falls back to hardcoded English messages when `t` is not provided.
- *
- * @param error - The raw error from Soroban
- * @param context - Contract context ('vault' | 'token-sale') to select the correct error map
- * @param t - Optional translation function from useTranslations("contractErrors")
- */
-export function extractContractError(
- error: unknown,
- context?: "vault" | "token-sale",
- t?: TranslationFn,
-): {
- message: string;
- details: string;
-} {
- const errorString =
- error instanceof Error ? error.message : String(error);
-
- // Try to extract error code from Soroban error response
- const errorCodeMatch = errorString.match(/Error\(Contract, #(\d+)\)/);
-
- if (errorCodeMatch) {
- const errorCode = parseInt(errorCodeMatch[1], 10);
-
- // Try i18n first
- if (t && context) {
- const i18nContext = context === "token-sale" ? "tokenSale" : context;
- const translatedMessage = t(`${i18nContext}.${errorCode}`);
-
- // next-intl returns the key path when translation is missing
- if (translatedMessage && !translatedMessage.includes(`${i18nContext}.${errorCode}`)) {
- return {
- message: t("title"),
- details: translatedMessage,
- };
- }
-
- // Fall back to unknownCode with the code param
- return {
- message: t("title"),
- details: t("unknownCode", { code: errorCode }),
- };
- }
-
- // Fallback to hardcoded messages
- const errorMap = context
- ? ERROR_MESSAGES_BY_CONTEXT[context]
- : undefined;
- const humanMessage = errorMap?.[errorCode];
-
- if (humanMessage) {
- return {
- message: "Contract Error",
- details: humanMessage,
- };
- }
-
- return {
- message: "Contract Error",
- details: `Contract error code ${errorCode}`,
- };
- }
-
- // Generic error response if no specific error code found
- return {
- message: t ? t("title") : "Contract Error",
- details: errorString,
- };
-}
diff --git a/apps/investor-tokenization/src/lib/sendTransactionService.ts b/apps/investor-tokenization/src/lib/sendTransactionService.ts
deleted file mode 100644
index bf8ddde..0000000
--- a/apps/investor-tokenization/src/lib/sendTransactionService.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "@tokenization/shared/lib/sendTransactionService";
diff --git a/apps/investor-tokenization/src/lib/sorobanClient.ts b/apps/investor-tokenization/src/lib/sorobanClient.ts
deleted file mode 100644
index 65b630c..0000000
--- a/apps/investor-tokenization/src/lib/sorobanClient.ts
+++ /dev/null
@@ -1,375 +0,0 @@
-import {
- Keypair,
- TransactionBuilder,
- Address,
- Contract,
- rpc,
- scValToNative,
- nativeToScVal,
- xdr,
- Networks,
- Operation,
- hash,
- Account,
-} from "@stellar/stellar-sdk";
-import { randomBytes } from "crypto";
-
-export type SorobanClientConfig = {
- rpcUrl: string;
- sourceSecret: string;
- fee?: string;
- timeoutSeconds?: number;
- maxAttempts?: number;
- pollDelayMs?: number;
-};
-
-// Type definitions using proper SDK types
-export type AccountLike = Account;
-export type ScVal = xdr.ScVal;
-
-// BASE_FEE constant from SDK (default minimum fee)
-const BASE_FEE = "100";
-
-type RequiredConfig = Required<
- Pick<
- SorobanClientConfig,
- "fee" | "timeoutSeconds" | "maxAttempts" | "pollDelayMs"
- >
->;
-
-export class SorobanClient {
- private readonly server: rpc.Server;
- private readonly keypair: Keypair;
- private readonly config: RequiredConfig;
-
- constructor({
- rpcUrl,
- sourceSecret,
- fee = BASE_FEE,
- timeoutSeconds = 300,
- maxAttempts = 60,
- pollDelayMs = 2000,
- }: SorobanClientConfig) {
- this.server = new rpc.Server(rpcUrl);
- this.keypair = Keypair.fromSecret(sourceSecret);
- this.config = {
- fee,
- timeoutSeconds,
- maxAttempts,
- pollDelayMs,
- };
- }
-
- get publicKey() {
- return this.keypair.publicKey();
- }
-
- nativeAddress(address: string): ScVal {
- return nativeToScVal(new Address(address), {
- type: "address",
- });
- }
-
- nativeString(value: string): ScVal {
- return xdr.ScVal.scvString(value);
- }
-
- nativeU32(value: number): ScVal {
- return xdr.ScVal.scvU32(value);
- }
-
- async uploadContractWasm(wasm: Buffer, label: string) {
- const result = await this.submitTransaction(
- (account) =>
- this.buildBaseTx(account)
- .addOperation(
- Operation.uploadContractWasm({
- wasm,
- }),
- )
- .setTimeout(this.config.timeoutSeconds)
- .build(),
- label,
- );
-
- if (!result.returnValue) {
- throw new Error(`${label} did not return a hash`);
- }
-
- return Buffer.from(scValToNative(result.returnValue) as Buffer);
- }
-
- async createContract(
- wasmHash: Buffer,
- constructorArgs: ScVal[],
- label: string,
- ) {
- const result = await this.submitTransaction(
- (account) =>
- this.buildBaseTx(account)
- .addOperation(
- Operation.createCustomContract({
- wasmHash,
- address: new Address(this.publicKey),
- salt: SorobanClient.randomSalt(),
- constructorArgs,
- }),
- )
- .setTimeout(this.config.timeoutSeconds)
- .build(),
- label,
- );
-
- if (!result.returnValue) {
- throw new Error(`${label} did not return an address`);
- }
-
- return Address.fromScVal(result.returnValue).toString();
- }
-
- async callContract(
- contractId: string,
- method: string,
- args: ScVal[],
- label: string,
- ) {
- await this.submitTransaction((account) => {
- const contract = new Contract(contractId);
- return this.buildBaseTx(account)
- .addOperation(contract.call(method, ...args))
- .setTimeout(this.config.timeoutSeconds)
- .build();
- }, label);
- }
-
- private buildBaseTx(account: AccountLike) {
- return new TransactionBuilder(account, {
- fee: this.config.fee,
- networkPassphrase: Networks.TESTNET,
- });
- }
-
- private static randomSalt() {
- return hash(randomBytes(32));
- }
-
- /**
- * Calculate a deterministic salt from a string seed
- */
- calculateDeterministicSalt(seed: string): Buffer {
- const seedBytes = Buffer.from(seed, "utf-8");
- return hash(seedBytes);
- }
-
- /**
- * Get contract address from salt and wasm hash (deterministic calculation)
- * This is used when simulation fails because contract already exists
- */
- async getContractAddressFromSalt(
- wasmHash: Buffer,
- salt: Buffer,
- ): Promise {
- // When a contract already exists, we cannot simulate its creation
- // The address is deterministic, but we can't easily calculate it without the network
- // The best we can do is throw a helpful error
- throw new Error(
- `Cannot determine address for existing contract. ` +
- `Contracts are already deployed for this escrowContractId. ` +
- `Please use a different escrowContractId or check if the contracts are already deployed.`
- );
- }
-
- /**
- * Create contract with a specific salt (for deterministic addresses)
- */
- async createContractWithSalt(
- wasmHash: Buffer,
- salt: Buffer,
- constructorArgs: ScVal[],
- label: string,
- ) {
- try {
- const result = await this.submitTransaction(
- (account) =>
- this.buildBaseTx(account)
- .addOperation(
- Operation.createCustomContract({
- wasmHash,
- address: new Address(this.publicKey),
- salt,
- constructorArgs,
- }),
- )
- .setTimeout(this.config.timeoutSeconds)
- .build(),
- label,
- );
-
- if (!result.returnValue) {
- throw new Error(`${label} did not return an address`);
- }
-
- return Address.fromScVal(result.returnValue).toString();
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- const errorStr = errorMessage.toLowerCase();
-
- // Check for contract already exists error (can appear in different formats)
- // Also check for the special marker we added in submitTransaction
- // The error can be: "HostError: Error(Storage, ExistingValue)" or "contract already exists"
- const isExistingContractError =
- errorMessage.includes("CONTRACT_ALREADY_EXISTS") ||
- errorStr.includes("contract already exists") ||
- errorStr.includes("existingvalue") ||
- (errorStr.includes("storage") && errorStr.includes("existing")) ||
- (errorStr.includes("hosterror") && errorStr.includes("storage") && errorStr.includes("existing"));
-
- if (isExistingContractError) {
- console.log(`${label} already exists, attempting to get address via simulation...`);
- // Try to get the address by simulating with the same parameters
- try {
- return await this.simulateContractCreation(wasmHash, salt, constructorArgs);
- } catch (simError) {
- const simErrorMsg = simError instanceof Error ? simError.message : String(simError);
- const simErrorStr = simErrorMsg.toLowerCase();
- // If simulation also fails with same error, the contract definitely exists
- // We can't easily get its address, so suggest using deploymentId
- if (simErrorStr.includes("contract already exists") ||
- simErrorStr.includes("existingvalue") ||
- (simErrorStr.includes("storage") && simErrorStr.includes("existing"))) {
- throw new Error(
- `Contracts are already deployed for this escrowContractId. ` +
- `To redeploy, please provide a 'deploymentId' parameter in your request ` +
- `(e.g., {"deploymentId": "v2"}) to create unique contract addresses. ` +
- `Alternatively, use a different escrowContractId.`
- );
- }
- // If simulation fails for other reason, throw original error
- throw error;
- }
- }
- throw error;
- }
- }
-
- /**
- * Simulate contract creation to get the address before deploying
- */
- async simulateContractCreation(
- wasmHash: Buffer,
- salt: Buffer,
- constructorArgs: ScVal[],
- ): Promise {
- const account = (await this.server.getAccount(this.publicKey)) as AccountLike;
-
- const transaction = this.buildBaseTx(account)
- .addOperation(
- Operation.createCustomContract({
- wasmHash,
- address: new Address(this.publicKey),
- salt,
- constructorArgs,
- }),
- )
- .setTimeout(30)
- .build();
-
- const preparedTx = await this.server.prepareTransaction(transaction);
- const simulation = await this.server.simulateTransaction(preparedTx);
-
- // Handle both success and error response types
- if ("error" in simulation) {
- const errorStr = JSON.stringify(simulation.error);
- // If contract already exists, try with empty args to get address
- if (errorStr.includes("contract already exists") || errorStr.includes("ExistingValue")) {
- return this.getContractAddressFromSalt(wasmHash, salt);
- }
- throw new Error(`Simulation failed: ${errorStr}`);
- }
-
- // Access result from success response
- if (!simulation.result?.retval) {
- throw new Error("Simulation did not return a contract address");
- }
-
- return Address.fromScVal(simulation.result.retval).toString();
- }
-
- private async submitTransaction(
- buildTx: (account: AccountLike) => ReturnType,
- label: string,
- ) {
- const account = (await this.server.getAccount(
- this.publicKey,
- )) as AccountLike;
- const tx = buildTx(account);
- const preparedTx = await this.server.prepareTransaction(tx);
- preparedTx.sign(this.keypair);
-
- let sendResponse;
- try {
- sendResponse = await this.server.sendTransaction(preparedTx);
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- const errorStr = errorMessage.toLowerCase();
- // Check if this is a "contract already exists" error from sendTransaction
- if (errorStr.includes("existingvalue") ||
- errorStr.includes("contract already exists") ||
- (errorStr.includes("storage") && errorStr.includes("existing"))) {
- throw new Error(`CONTRACT_ALREADY_EXISTS: ${errorMessage}`);
- }
- throw error;
- }
-
- const result = await this.waitForTransaction(sendResponse.hash, label);
-
- if (result.status !== "SUCCESS") {
- // Parse the error to extract useful information
- // The error is in resultXdr for failed transactions
- // Also check the entire result object for error information
- let errorDetails = "Unknown error";
- if (result.resultXdr) {
- errorDetails = String(result.resultXdr);
- }
-
- // Also check if there's error information in the result object itself
- const resultStr = JSON.stringify(result);
- const errorMessage = `${label} failed: ${errorDetails}`;
-
- // Check if this is a "contract already exists" error
- // The error can appear as: "Error(Storage, ExistingValue)" or "contract already exists"
- // Check both errorDetails and the full result string
- const errorStr = (errorDetails + " " + resultStr).toLowerCase();
- const isExistingContractError =
- errorStr.includes("existingvalue") ||
- errorStr.includes("contract already exists") ||
- (errorStr.includes("storage") && errorStr.includes("existing")) ||
- (errorStr.includes("hosterror") && errorStr.includes("storage"));
-
- if (isExistingContractError) {
- throw new Error(`CONTRACT_ALREADY_EXISTS: ${errorMessage}`);
- }
-
- throw new Error(errorMessage);
- }
-
- return result;
- }
-
- private async waitForTransaction(hash: string, label: string) {
- for (let attempt = 0; attempt < this.config.maxAttempts; attempt += 1) {
- const txResult = await this.server.getTransaction(hash);
- if (txResult.status === "SUCCESS" || txResult.status === "FAILED") {
- return txResult;
- }
- // Continue polling while the transaction is not yet finalized on chain
- // Some RPCs may report PENDING or NOT_FOUND until the transaction is included
-
- await new Promise((resolve) =>
- setTimeout(resolve, this.config.pollDelayMs),
- );
- }
-
- throw new Error(`${label} timeout: transaction not found on network`);
- }
-}
diff --git a/apps/investor-tokenization/src/lib/toastWithTx.tsx b/apps/investor-tokenization/src/lib/toastWithTx.tsx
deleted file mode 100644
index 9599813..0000000
--- a/apps/investor-tokenization/src/lib/toastWithTx.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from "react";
-import { toast } from "sonner";
-import { Button } from "@tokenization/ui/button";
-import Link from "next/link";
-
-const EXPLORER_BASE = "https://stellar.expert/explorer/testnet";
-
-export function toastSuccessWithTx(message: string, txHash?: string) {
- const href = txHash ? `${EXPLORER_BASE}/tx/${txHash}` : EXPLORER_BASE;
-
- toast.custom(
- () => (
-
- {message}
-
-
-
-
- ),
- { duration: 5000 }
- );
-}
diff --git a/apps/investor-tokenization/src/lib/tokenDeploymentService.ts b/apps/investor-tokenization/src/lib/tokenDeploymentService.ts
deleted file mode 100644
index fde1e35..0000000
--- a/apps/investor-tokenization/src/lib/tokenDeploymentService.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import fs from "fs";
-import path from "path";
-import { SorobanClient } from "./sorobanClient";
-
-const tokenSalePath = path.join(process.cwd(), "services/wasm/token_sale.wasm");
-const tokenFactoryPath = path.join(
- process.cwd(),
- "services/wasm/soroban_token_contract.wasm",
-);
-
-export type TokenDeploymentParams = {
- escrowContractId: string;
- tokenName: string;
- tokenSymbol: string;
-};
-
-export type TokenDeploymentResult = {
- tokenFactoryAddress: string;
- tokenSaleAddress: string;
-};
-
-export const deployTokenContracts = async (
- client: SorobanClient,
- { escrowContractId, tokenName, tokenSymbol }: TokenDeploymentParams,
-): Promise => {
- const tokenFactoryWasm = fs.readFileSync(tokenFactoryPath);
- const tokenSaleWasm = fs.readFileSync(tokenSalePath);
-
- // Upload WASM files in parallel for better performance
- const [tokenFactoryWasmHash, tokenSaleWasmHash] = await Promise.all([
- client.uploadContractWasm(
- tokenFactoryWasm,
- "TokenFactory WASM upload",
- ),
- client.uploadContractWasm(
- tokenSaleWasm,
- "TokenSale WASM upload",
- ),
- ]);
-
- // SOLUTION: Deploy TokenSale first with placeholder, then deploy TokenFactory,
- // then update TokenSale with the real TokenFactory address using set_token
-
- // Step 1: Deploy TokenSale first with placeholder token address (deployer address)
- // This allows us to get the TokenSale address for TokenFactory deployment
- console.log("Deploying TokenSale...");
- const tokenSaleAddress = await client.createContract(
- tokenSaleWasmHash,
- [
- client.nativeAddress(escrowContractId), // escrow_contract
- client.nativeAddress(client.publicKey), // sale_token (placeholder - will be updated)
- client.nativeAddress(client.publicKey), // admin (deployer can update token address)
- ],
- "TokenSale contract creation",
- );
- console.log(`TokenSale deployed at: ${tokenSaleAddress}`);
-
- // Step 2: Deploy TokenFactory with TokenSale address as mint_authority
- console.log("Deploying TokenFactory...");
- console.log(`Deployer public address: ${client.publicKey}`);
- console.log(`Mint authority: ${tokenSaleAddress}`);
- const tokenFactoryAddress = await client.createContract(
- tokenFactoryWasmHash,
- [
- client.nativeString(tokenName), // name (user-provided)
- client.nativeString(tokenSymbol), // symbol (user-provided)
- client.nativeString(escrowContractId), // escrow_id
- client.nativeU32(7), // decimal
- client.nativeAddress(tokenSaleAddress), // mint_authority (Token Sale contract)
- ],
- "TokenFactory contract creation",
- );
- console.log(`TokenFactory deployed at: ${tokenFactoryAddress}`);
-
- // Step 3: Update TokenSale with the real TokenFactory address
- console.log("Updating TokenSale with correct token address...");
- await client.callContract(
- tokenSaleAddress,
- "set_token",
- [client.nativeAddress(tokenFactoryAddress)],
- "Update TokenSale token address",
- );
- console.log("✅ TokenSale updated successfully with correct token address.");
-
- return { tokenFactoryAddress, tokenSaleAddress };
-};
diff --git a/apps/investor-tokenization/src/lib/utils.ts b/apps/investor-tokenization/src/lib/utils.ts
deleted file mode 100644
index c66271b..0000000
--- a/apps/investor-tokenization/src/lib/utils.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { cn } from "@tokenization/shared/lib/utils";
diff --git a/apps/investor-tokenization/src/lib/vaultDeploymentService.ts b/apps/investor-tokenization/src/lib/vaultDeploymentService.ts
deleted file mode 100644
index 81c8a4a..0000000
--- a/apps/investor-tokenization/src/lib/vaultDeploymentService.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import * as StellarSDK from "@stellar/stellar-sdk";
-import fs from "fs";
-import path from "path";
-import { SorobanClient } from "./sorobanClient";
-
-const vaultContractPath = path.join(
- process.cwd(),
- "services/wasm/vault_contract.wasm",
-);
-
-export type VaultDeploymentParams = {
- admin: string;
- enabled: boolean;
- price: number | string;
- token: string;
- usdc: string;
-};
-
-export type VaultDeploymentResult = {
- vaultContractAddress: string;
-};
-
-export const deployVaultContract = async (
- client: SorobanClient,
- { admin, enabled, price, token, usdc }: VaultDeploymentParams,
-): Promise => {
- const vaultWasm = fs.readFileSync(vaultContractPath);
-
- const vaultWasmHash = await client.uploadContractWasm(
- vaultWasm,
- "Vault WASM upload",
- );
-
- const vaultContractAddress = await client.createContract(
- vaultWasmHash,
- [
- client.nativeAddress(admin),
- StellarSDK.nativeToScVal(enabled, { type: "bool" }),
- StellarSDK.nativeToScVal(price, { type: "i128" }),
- client.nativeAddress(token),
- client.nativeAddress(usdc),
- ],
- "Vault contract creation",
- );
-
- return { vaultContractAddress };
-};
diff --git a/apps/investor-tokenization/src/utils/adjustedAmounts.ts b/apps/investor-tokenization/src/utils/adjustedAmounts.ts
deleted file mode 100644
index 4d53f89..0000000
--- a/apps/investor-tokenization/src/utils/adjustedAmounts.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import Decimal from "decimal.js";
-
-const USDC_DECIMAL_SCALE = 1e7;
-
-export function adjustPricesToMicroUSDC(price: number): string {
- if (!Number.isFinite(price) || price < 0) {
- throw new Error("Price must be a finite, non-negative number");
- }
-
- const priceDecimal = new Decimal(price.toString());
- const microUSDCDecimal = priceDecimal.times(USDC_DECIMAL_SCALE);
-
- const rounded = microUSDCDecimal.toDecimalPlaces(0, Decimal.ROUND_HALF_EVEN);
-
- return rounded.toFixed(0);
-}
-
-export function fromStroops(stroops: number | string): number {
- const val = new Decimal(String(stroops));
- return val.div(USDC_DECIMAL_SCALE).toNumber();
-}
diff --git a/docs/postman-flows.md b/docs/POSTMAN.md
similarity index 100%
rename from docs/postman-flows.md
rename to docs/POSTMAN.md
diff --git a/docs/REFACTOR_BACKOFFICE_INVESTOR.md b/docs/REFACTOR_BACKOFFICE_INVESTOR.md
new file mode 100644
index 0000000..f3ebe19
--- /dev/null
+++ b/docs/REFACTOR_BACKOFFICE_INVESTOR.md
@@ -0,0 +1,187 @@
+# Refactor backlog (Backoffice e Investor)
+
+Generado a partir de una revisión de `apps/backoffice-tokenization`, `apps/investor-tokenization` y código compartido relevante (`packages/*`), siguiendo `rules/DAPPS.mdc`.
+
+## Top oportunidades (priorizadas por esfuerzo)
+
+### 1) I18n: eliminar strings hardcodeadas en diálogos y toasts
+**Esfuerzo:** S
+**Impacto:** mejora consistencia UX, reduce “mezcla de idiomas” y evita mensajes no traducidos.
+**Evidencia:**
+- `apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx` (hardcode en `DialogTitle`, `DialogDescription`, `toast.success(...)`, botón y spinner).
+- `apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts` (hardcode en mensajes de error/toast).
+- `apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx` (errores lanzados/derivados desde strings hardcodeadas; algunos son mostrados directamente al usuario).
+**Propuesta:**
+1. Pasar todas las strings de UI y errores “usuario-facing” por `useTranslations(...)` (o por el sistema de mensajes equivalente ya existente).
+2. Mantener el “fallback” solo para errores verdaderamente inesperados (idealmente también traducible).
+**Riesgo:** bajo; requiere solo refactor de mapeo de mensajes, sin tocar lógica de transacción.
+
+---
+
+### 2) Formularios: reemplazar validación manual por `react-hook-form` + `zod`
+**Esfuerzo:** S
+**Impacto:** menor riesgo de edge cases, consistencia entre apps, menos lógica ad-hoc por input.
+**Evidencia:**
+- `apps/backoffice-tokenization/src/features/campaigns/components/roi/FundRoiDialog.tsx` (validación con `Number(amount)` en `handleSubmit`).
+- `apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx` (validación manual con rangos `0..100`).
+**Propuesta:**
+1. Crear schemas `zod` para cada formulario (cantidad / porcentaje).
+2. Implementar hooks `useForm` y `handleSubmit` en un hook dedicado (o directamente dentro del componente si es trivial).
+3. Garantizar que los mensajes de error salgan por `FormMessage`/UI estándar.
+**Riesgo:** bajo/medio; revisar compatibilidad con el estado actual (por ejemplo reseteo de `amount`/`percentage` cuando se cierra el diálogo).
+
+---
+
+### 3) Dead code / duplicación de UI: componentes de “campañas” que parecen no usarse
+**Esfuerzo:** S
+**Impacto:** menos superficie de mantenimiento y menos confusión sobre cuál patrón usar.
+**Evidencia / motivo de sospecha:**
+- Backoffice mantiene `apps/backoffice-tokenization/src/features/campaigns/components/campaigns-view.tsx` + `campaign-toolbar.tsx` + `campaign-search.tsx` + `campaign-filter.tsx`, pero los pages usan `SharedCampaignsView` de `packages/features`.
+- Investor mantiene `apps/investor-tokenization/src/features/roi/components/campaign-toolbar.tsx` + `campaign-search.tsx` + `campaign-filter.tsx`, pero en `my-investments/page.tsx` se usa `SharedCampaignsView` del paquete (que ya trae toolbar).
+**Propuesta:**
+1. Confirmar por búsqueda de imports (y/o tests/compilación) que no se usan.
+2. Si no se usan: eliminar o convertir en componentes “legacy” claramente marcados.
+3. Si se usan en algún route no cubierto: re-engancharlos a `packages/features` para evitar bifurcar el UX.
+**Riesgo:** bajo si se valida con build/compilación; medio si existen rutas “raras” no cubiertas.
+
+---
+
+### 4) Eliminar `any` y `eslint-disable` en `ManageLoansView`
+**Esfuerzo:** M
+**Impacto:** robustez (tipado real), reduce riesgo de fallos silenciosos y mejora mantenibilidad.
+**Evidencia:**
+- `apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx`:
+ - `eslint-disable-next-line @typescript-eslint/no-explicit-any`
+ - `const data = (...) as any;`
+**Propuesta:**
+1. Tipar la respuesta de `getEscrowByContractIds` (o envolverla con un mapper seguro).
+2. Evitar asumir `data[0]` sin validaciones de forma (`Array.isArray` + guard clauses).
+3. Remover el `eslint-disable` una vez el tipo quede expresado.
+**Riesgo:** medio; requiere entender el tipo real que retorna el indexer.
+
+---
+
+### 5) Investor: optimizar invalidación/refetch y manejo de errores en `InvestDialog`
+**Esfuerzo:** M
+**Impacto:** performance (menos requests duplicadas), mejora UX ante errores, menos “cascadas” de estado.
+**Evidencia:**
+- `apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx`:
+ - secuencia repetida:
+ - `invalidateQueries(...)` y luego `refetchQueries(...)` para las mismas queryKeys (`balanceQueryKey`, `singleEscrowKey`, `["escrows-by-ids"]`).
+ - `console.error("Failed to save investment to database:", dbError);` (no necesariamente usuario-facing).
+ - parseo de errores por `message.includes(...)` (heurística frágil).
+**Propuesta:**
+1. Mantener solo `invalidateQueries(...)` (usualmente suficiente con React Query) o definir `staleTime`/config para evitar refetch inmediato.
+2. Centralizar el mapeo de errores de Soroban (o los del core) hacia mensajes traducidos (en vez de heurísticas por substrings).
+3. Convertir `console.error` a un logger consistente o manejar el error con toast cuando afecte al usuario.
+**Riesgo:** medio; requiere validar comportamiento del cache en la pantalla (carousel/balance/progreso).
+
+---
+
+### 6) Reducir complejidad O(n) y mapeos repetidos en páginas
+**Esfuerzo:** S
+**Impacto:** baja pero limpia performance y simplifica lógica.
+**Evidencia:**
+- `apps/investor-tokenization/src/app/[locale]/my-investments/page.tsx`:
+ - `handleClaimRoi` usa `campaigns.find(...)` en cada click.
+ - `aggregateByCampaign` realiza una agregación correcta con `Map`, pero el `find` puede volverse O(n) por interacción.
+**Propuesta:**
+1. Mantener un `Map` memoizado por `campaigns` para resolver por id en O(1).
+2. (Opcional) similar para backoffice si hay “find/filter” repetidos.
+**Riesgo:** bajo.
+
+---
+
+### 7) Consolidar flujo transaccional duplicado (ROI + enable/disable vault)
+**Esfuerzo:** M
+**Impacto:** gran mejora de mantenibilidad y reducción de bugs por divergencia.
+**Evidencia (patrón repetido):**
+- Backoffice:
+ - `apps/backoffice-tokenization/src/features/campaigns/hooks/useFundRoi.ts`
+ - `apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts`
+ - `apps/backoffice-tokenization/src/features/campaigns/hooks/useToggleVault.ts`
+ - `apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts`
+- Investor:
+ - `apps/investor-tokenization/src/features/claim-roi/hooks/useClaimROI.ts` (builder -> sign -> submit -> toast)
+ - `apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx` también incluye sign+submit, aunque con lógica adicional.
+**Comparación rápida del patrón:**
+1. Validar wallet conectado.
+2. Construir `unsignedXdr`.
+3. `signTransaction({ unsignedTransaction, address })`.
+4. Enviar:
+ - Backoffice: vía `submitSignedTransactionAndWait(...)` (RPC directo) -> parse extra.
+ - Investor: vía `SendTransactionService` (API helper).
+5. Actualizar queries / toasts.
+**Propuesta (a nivel de diseño):**
+1. Crear un hook/utility compartido en `packages/*` (posiblemente `packages/shared` o `packages/tw-blocks-shared`) tipo:
+ - `useExecuteSignedXdr(...)` o `executeSignedXdr(...)`
+2. Parámetros configurables:
+ - `buildUnsignedXdr: () => Promise`
+ - `submitMode: "directRpc" | "api"`
+ - `onSuccess`, `onError`, `queryInvalidations` (opcional)
+3. Reutilizarlo en todos los hooks anteriores.
+**Riesgo:** medio; hay dos modos de envío (direct RPC vs API), pero el diseño puede unificarse con adaptadores.
+
+---
+
+### 8) Consolidar UI de “CampaignCard” entre Backoffice e Investor
+**Esfuerzo:** L
+**Impacto:** reduce duplicación de lógica, asegura consistencia visual y minimiza divergencias.
+**Evidencia (duplicación):**
+- Backoffice: `apps/backoffice-tokenization/src/components/shared/campaign-card.tsx`
+- Investor: `apps/investor-tokenization/src/components/shared/campaign-card.tsx`
+Ambos renderizan:
+- status badge (via `getCampaignStatusConfig`)
+- milestones (misma lógica de flags `approved/released`)
+- query para obtener escrow milestones/balance (misma estructura de `useQuery` por card)
+**Propuesta:**
+1. Extraer a `packages/features` o `packages/ui` una `CampaignCard` parametrizable:
+ - `cardMode: "backoffice" | "investor"`
+ - callbacks: `onManageLoans?(id)`, `onClaimRoi?(id)` o `actionsSlot`
+ - label/formatters de footer (balance/poolSize)
+2. Mantener la lógica compartida de milestones y el “fetch de escrow” en un solo lugar.
+**Riesgo:** medio/alto; requiere definir API de componentes y validar el mapping entre los tipos de campaña (backoffice vs investor).
+
+---
+
+### 9) Modularizar componentes monolíticos: `ManageLoansView` e `InvestDialog`
+**Esfuerzo:** L
+**Impacto:** reduce complejidad ciclomática y facilita testing/iteración.
+**Evidencia:**
+- `apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx` (fetch + approve/release + dialogs + add milestone en un solo componente).
+- `apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx` (wizard trustline/buy + múltiples renders + invalidations + persistencia + cálculo estimaciones).
+**Propuesta:**
+1. Extraer subcomponentes:
+ - “Milestones list”
+ - “Approve/Release action”
+ - “Change status dialog”
+ - “Add milestone form”
+2. Extraer hooks:
+ - `useManageLoans` / `useInvestFlow` para orquestación de pasos + estado.
+3. Mantener componentes de UI “presentacionales” (según `DAPPS.mdc`).
+**Riesgo:** alto; aunque es refactor, tocará props/estado y requiere buena regresión manual.
+
+---
+
+## Roadmap sugerido
+
+### Fase 1 (rápida, bajo riesgo)
+- I18n: eliminar hardcode en `UpdateRoiDialog` y mensajes usuario-facing.
+- Migrar `FundRoiDialog` + `UpdateRoiDialog` a `react-hook-form` + `zod`.
+- Remover dead code de toolbars/views duplicadas que no se usan.
+- Pequeñas optimizaciones (Map para lookups en `my-investments/page.tsx`).
+
+### Fase 2 (mantenibilidad + performance)
+- Quitar `any`/`eslint-disable` en `ManageLoansView`.
+- Reducir invalidation/refetch duplicado en `InvestDialog`.
+- Diseñar/introducir abstracción de ejecución transaccional (sin implementarla aún si se requiere una coordinación fina).
+
+### Fase 3 (consolidación fuerte)
+- Unificar `CampaignCard` en `packages/*`.
+- Modularizar `ManageLoansView` e `InvestDialog` con hooks/subcomponents.
+- Extraer una capa unificada de “submit signed transaction + onSuccess/onError + toast” para ambos apps.
+
+## Candidatos “de apoyo” (verificar)
+- `packages/shared/src/lib/contractErrorHandler.ts`: revisar si está muerto.
+ - `extractContractError` no parece referenciarse desde `apps/backoffice-tokenization` ni `apps/investor-tokenization`.
+
diff --git a/docs/TOKENIZE-ESCROW.md b/docs/TOKENIZE-ESCROW.md
index 35a3f1c..f936c54 100644
--- a/docs/TOKENIZE-ESCROW.md
+++ b/docs/TOKENIZE-ESCROW.md
@@ -6,8 +6,7 @@ This document maps the current tokenize escrow flow, identifying all components,
## File Paths
### Backoffice UI Components
-- **Route/Page**: `apps/backoffice-tokenization/src/app/manage-escrows/page.tsx`
-- **View Component**: `apps/backoffice-tokenization/src/features/manage-escrows/ManageEscrowsView.tsx`
+- **UI Route/Page**: _Not currently wired in `apps/backoffice-tokenization/src/app`_
- **Dialog Component**: `apps/backoffice-tokenization/src/features/tokens/deploy/dialog/TokenizeEscrow.tsx`
- **Hook**: `apps/backoffice-tokenization/src/features/tokens/deploy/dialog/useTokenizeEscrow.ts`
- **Service**: `apps/backoffice-tokenization/src/features/tokens/services/token.service.ts`
@@ -44,9 +43,8 @@ Form values are typed in `useTokenizeEscrow.ts` as `TokenizeEscrowFormValues` an
```
User (Browser)
│
- ├─> [1] Navigate to /manage-escrows
- │ └─> ManageEscrowsView.tsx
- │ └─> TokenizeEscrowDialog (Button trigger)
+ ├─> [1] Navigate to the UI entry point (TBD)
+ │ └─> TokenizeEscrowDialog (Button trigger)
│
├─> [2] User clicks "Tokenize Escrow" button
│ └─> Opens TokenizeEscrowDialog
diff --git a/docs/TOKEN_BALANCE_SYSTEM.md b/docs/TOKEN_BALANCE_SYSTEM.md
index 78c43b3..17fa05d 100644
--- a/docs/TOKEN_BALANCE_SYSTEM.md
+++ b/docs/TOKEN_BALANCE_SYSTEM.md
@@ -21,11 +21,10 @@ All paths below are relative to `apps/investor-tokenization/`.
3. **Hooks** (`src/features/investments/hooks/`)
- `useUserInvestments` — Fetches user's investments with balance > 0
- - `useProjectTokenBalances` — Fetches balances for all projects (used by carousel/home)
4. **Components**
- - `src/features/investments/components/` — `InvestmentCard`, `InvestmentsView`
- - `src/features/transparency/` — `ProjectList`, `Carousel` (use `useProjectTokenBalances` for balances on cards)
+ - `src/features/investments/components/` — `InvestmentCard`
+ - `src/features/transparency/` — `ProjectList` (no dedicated carousel component)
## How Token Balance Reading Works
@@ -226,37 +225,6 @@ Fetches all investments where the user has a token balance > 0.
- Includes token metadata
- Caches results for 2 minutes
-### useProjectTokenBalances
-
-Fetches token balances for all projects (used in carousel/home page).
-
-**Returns:**
-```typescript
-{
- data: Record,
- isLoading: boolean,
- isError: boolean
-}
-```
-
-**ProjectTokenBalanceInfo Type:**
-```typescript
-{
- escrowId: string,
- tokenFactory: string,
- balance: string,
- tokenName?: string,
- tokenSymbol?: string,
- tokenDecimals?: number
-}
-```
-
-**Features:**
-- Fetches balances for all projects in parallel
-- Returns a map of `escrowId -> balance info`
-- Includes token metadata
-- Used to display balances on project cards
-
## Balance Formatting
### Raw vs Formatted Balance
@@ -286,12 +254,9 @@ formattedBalance.toLocaleString(undefined, {
## Project Data Structure
-The list of projects used for **balance checks** is defined in the hooks (duplicated in each hook that needs it):
-
-- `useProjectTokenBalances.hook.ts` — `PROJECT_DATA` (used for carousel/home balances)
-- `useUserInvestments.hook.ts` — same `PROJECT_DATA` (used for investments view)
+The list of projects used for **balance checks** is defined in the hooks:
-The **carousel UI** gets project cards from `ProjectList.tsx` (`data` array with `escrowId`, `tokenSale`, `tokenFactory`, and optional `src`/`content`). The balances shown on each card come from `useProjectTokenBalances()`, which calls the token-balance API for each project in `PROJECT_DATA`.
+- `useUserInvestments.hook.ts` — `PROJECT_DATA`
**Example PROJECT_DATA (in hooks):**
diff --git a/docs/audits/smart-contracts/vault-contract/SECURITY_AUDIT_VAULT_CONTRACT-V1.0.0.md b/docs/audits/smart-contracts/vault-contract/SECURITY_AUDIT_VAULT_CONTRACT-V1.0.0.md
deleted file mode 100644
index b1e4817..0000000
--- a/docs/audits/smart-contracts/vault-contract/SECURITY_AUDIT_VAULT_CONTRACT-V1.0.0.md
+++ /dev/null
@@ -1,302 +0,0 @@
-# Security Audit: VaultContract
-
-**Date:** March 2025
-**Contract:** `vault-contract`
-**Scope:** Audit report.
-**Author:** [@Villarley](https://github.com/Villarley)
-
----
-
-## Summary Table
-
-| Severity | Count | Findings |
-|----------|-------|----------|
-| **Critical** | 2 | F-01, F-02 |
-| **High** | 3 | F-03, F-04, F-05 |
-| **Medium** | 5 | F-06, F-07, F-08, F-09, F-10 |
-| **Low** | 3 | F-11, F-12, F-13 |
-| **Informational** | 2 | F-14, F-15 |
-| **Enhancement** | 1 | F-17 (Scout) |
-
-**Scout (static analysis):** 4 critical, 5 medium, 1 enhancement. See [Scout Findings](#scout-findings) section.
-
----
-
-## Executive Summary
-
-The following files of the `vault-contract` were audited in their current state:
-
-- [`vault.rs`](../apps/smart-contracts/contracts/vault-contract/src/vault.rs) — main contract logic
-- [`error.rs`](../apps/smart-contracts/contracts/vault-contract/src/error.rs) — typed errors
-- [`events.rs`](../apps/smart-contracts/contracts/vault-contract/src/events.rs) — event emission
-- [`storage_types.rs`](../apps/smart-contracts/contracts/vault-contract/src/storage_types.rs) — storage keys
-- [`test.rs`](../apps/smart-contracts/contracts/vault-contract/src/test.rs) — contract tests
-
----
-
-## Findings by Severity
-
-### CRITICAL
-
-#### F-01: Missing TTL Extension in Storage
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Critical |
-| **Description** | The contract does not call `extend_ttl` in any operation. In Soroban, instance storage expires if not extended periodically. The `token-factory` contract does extend TTL on every operation that modifies or reads state (see [`contract.rs:86-89`](../apps/smart-contracts/contracts/token-factory/src/contract.rs)). |
-| **Impact** | If TTL expires, the vault state (admin, enabled, roi_percentage, token_address, usdc_address, total_tokens_redeemed) is lost. USDC funds would be trapped in the contract address with no way to recover them. |
-| **Recommendation** | Add `env.storage().instance().extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT)` in all functions that read or write storage, following the token-factory pattern. Define constants similar to token-factory's `storage_types.rs`. |
-
----
-
-#### F-02: Arithmetic Overflow in Claim Formula
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Critical |
-| **Description** | The expression `token_balance * (100 + roi_percentage) / 100` (lines 172 and 304) uses unverified arithmetic. With extreme values it can cause `i128` overflow. |
-| **Impact** | Overflow can cause panic or incorrect results. Although amounts are typically bounded in practice, an attacker with very large tokens or a misconfigured ROI could exploit this. |
-| **Recommendation** | Use `checked_mul` and `checked_div`. Add `ArithmeticOverflow` variant to `ContractError` and validate in the constructor that `roi_percentage` is within a reasonable range (e.g. 0–1000). |
-
----
-
-### HIGH
-
-#### F-03: Constructor Without Input Validation
-
-| Field | Details |
-|-------|---------|
-| **Severity** | High |
-| **Description** | `__constructor` (lines 60-80) does not validate parameters. It accepts: negative or extreme `roi_percentage` (e.g. `i128::MAX`); arbitrary `admin`, `token`, `usdc` addresses (including malicious contracts); `token == usdc` (same contract for token and USDC). |
-| **Impact** | Vault deployed with invalid or malicious configuration; loss of funds or unexpected behavior. |
-| **Recommendation** | Validate in constructor: `0 <= roi_percentage <= ROI_MAX`; `admin`, `token`, `usdc` are not zero address; `token != usdc`. Optionally verify that `token` and `usdc` implement the expected interface. |
-
----
-
-#### F-04: Use of `.expect()` in Critical Paths
-
-| Field | Details |
-|-------|---------|
-| **Severity** | High |
-| **Description** | There are 14 uses of `.expect()` in `vault.rs` (lines 146, 156, 162, 177, 221, 237, 245, 253, 262, 297, 314, 343, 361, 367). If the key does not exist (corrupt storage, expired TTL, incorrect migration), the contract panics. |
-| **Impact** | Runtime panic; failed transaction; possible gas/fee loss without useful message for the user. |
-| **Recommendation** | Replace with `ok_or(ContractError::XxxNotFound)?` in functions that return `Result`. In getters that return values directly, document that they assume initialized state and consider returning `Result` or a safe default where appropriate. |
-
----
-
-#### F-05: Overflow in `TotalTokensRedeemed`
-
-| Field | Details |
-|-------|---------|
-| **Severity** | High |
-| **Description** | At line 198: `total_redeemed + token_balance` can overflow `i128` if the accumulated total is very large. |
-| **Impact** | Overflow → panic; counter desynchronized from actual state if wrapping arithmetic were used. |
-| **Recommendation** | Use `checked_add` and propagate the error with `ContractError::ArithmeticOverflow`. |
-
----
-
-### MEDIUM
-
-#### F-06: `preview_claim` vs `claim` Divergence on Negative ROI / Underflow in `roi_amount`
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Medium (Scout: CRITICAL for underflow) |
-| **Description** | `preview_claim` uses `unwrap_or(0)` for `roi_percentage` (line 291), while `claim` uses `expect` (line 156). With negative ROI, `roi_amount = usdc_amount - token_balance` (line 308) can cause **underflow** if `usdc_amount < token_balance`. Scout: `[CRITICAL] This subtraction operation could underflow`. |
-| **Impact** | Underflow → panic; preview can show values inconsistent with actual `claim` result in edge cases. |
-| **Recommendation** | Use `checked_sub` for `roi_amount`. Unify storage handling and validate `roi_percentage >= 0` in constructor to avoid negative ROI. |
-
----
-
-#### F-07: Events Without `#[contractevent]`
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Medium |
-| **Description** | Events are emitted with `env.events().publish((symbol_short!("claim"),), event)` instead of the `#[contractevent]` macro recommended by the SDK. The escrow contract uses `#[contractevent]` in [`events/handler.rs`](../apps/smart-contracts/contracts/escrow/src/events/handler.rs). |
-| **Impact** | Events are not included in the contract spec; indexers and clients lack auto-generated types; reduced interoperability. |
-| **Recommendation** | Migrate to `#[contractevent]` with appropriate topics and `#[topic]` on key fields (beneficiary, etc.) for indexing. |
-
----
-
-#### F-08: No Validation of External Contract Addresses
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Medium |
-| **Description** | The `token` and `usdc` addresses are stored without verifying they are valid token contracts. A malicious admin or deployment error could point to malicious contracts. |
-| **Impact** | Malicious contracts could implement callbacks in `burn`/`transfer` and cause reentrancy or incorrect logic. |
-| **Recommendation** | Document that the deployer must use trusted addresses. Optionally: if the ecosystem provides a token registry, validate against it. Assume the admin is trusted. |
-
----
-
-#### F-09: Authorization Pattern in `availability_for_exchange`
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Medium |
-| **Description** | The function receives `admin` as a parameter and calls `admin.require_auth()` followed by `admin != stored_admin`. Scout: `[MEDIUM] Usage of admin parameter might be unnecessary` — suggests retrieving admin from storage instead of passing it as a parameter. |
-| **Impact** | The pattern is correct. An attacker cannot impersonate the admin without the keys. Best practice is to obtain admin from storage; current design is acceptable but can be improved for clarity. |
-| **Recommendation** | Read `stored_admin` first and use `stored_admin.require_auth()` to make explicit that the stored admin is authorized, not the parameter. Remove the `admin` parameter if the signature allows. |
-
----
-
-#### F-10: Constructor Re-invocation
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Medium |
-| **Description** | There is no explicit protection against a second invocation of `__constructor`. In Soroban the constructor runs on deployment; re-invocation depends on the upgrade/redeploy model. |
-| **Impact** | If the constructor could be called again in some upgrade flow, all state would be overwritten. |
-| **Recommendation** | Add an initialization flag (like token-factory's `write_escrow_id`/`write_mint_authority`) that panics if already initialized. Document expected behavior on upgrades. |
-
----
-
-### LOW
-
-#### F-11: Use of `i128` for Non-negative Amounts
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Low |
-| **Description** | Balances, amounts, and ROI are represented with `i128`. Financial amounts are typically non-negative. |
-| **Impact** | Allows negative values that are later rejected at runtime; increases surface for sign-related errors. |
-| **Recommendation** | Consider newtypes or early validation. Short term: validate `amount >= 0` in constructor for `roi_percentage` and any amount input. |
-
----
-
-#### F-12: Inconsistency Between `unwrap_or` and `expect` in Getters
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Low |
-| **Description** | `is_enabled` and `get_total_tokens_redeemed` use `unwrap_or(false)`/`unwrap_or(0)`; other getters use `expect`. Inconsistent behavior when storage is empty. |
-| **Impact** | Makes it harder to reason about contract state when data is missing. |
-| **Recommendation** | Define a clear policy: either all getters assume initialized state (and use `expect` with clear messages), or they return `Result`/documented default values consistently. |
-
----
-
-#### F-13: `preview_claim` Does Not Return `Result`
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Low |
-| **Description** | `preview_claim` returns `ClaimPreview` directly. If storage read fails (e.g. `expect` on token_address or usdc_address), it panics. |
-| **Impact** | A read-only function can panic instead of returning a controlled error. |
-| **Recommendation** | Change signature to `Result` and propagate errors, or document that it assumes correctly initialized contract. |
-
----
-
-### INFORMATIONAL
-
-#### F-14: Additional Tests Recommended
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Informational |
-| **Description** | Current tests do not explicitly cover: overflow in claim formula; negative ROI in constructor; behavior with expired TTL (when TTL is implemented); reentrancy scenarios (if custom tokens are used). |
-| **Impact** | Lower coverage of edge cases and attack scenarios. |
-| **Recommendation** | Add tests for overflow, constructor validation, and (when applicable) TTL and reentrancy. |
-
----
-
-#### F-15: Code Quality and `no_std`
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Informational |
-| **Description** | The crate uses `#![no_std]` correctly. Tests use `extern crate std` only under `#[cfg(test)]`, which is appropriate. |
-| **Impact** | None significant. |
-| **Recommendation** | Ensure no dependencies introduce `std` into the production binary. |
-
----
-
-## Claim Flow Diagram and Failure Points
-
-```mermaid
-flowchart TD
- subgraph claim [claim]
- A[require_auth beneficiary] --> B[Read enabled, roi, token, usdc]
- B --> C[Get token_balance]
- C --> D{balance > 0?}
- D -->|No| E[Err BeneficiaryHasNoTokensToClaim]
- D -->|Yes| F["usdc_amount = balance * (100+roi)/100"]
- F --> G{Overflow?}
- G -->|Yes| H[Panic]
- G -->|No| I{vault_usdc >= usdc_amount?}
- I -->|No| J[Err VaultDoesNotHaveEnoughUSDC]
- I -->|Yes| K[token_client.burn]
- K --> L[usdc_client.transfer]
- L --> M[total_redeemed += token_balance]
- M --> N{Overflow?}
- N -->|Yes| O[Panic]
- N -->|No| P[Emit event]
- end
-```
-
----
-
-## Related Open Issues
-
-No open issues were found in the repository that explicitly address these findings.
-
----
-
-## Report Acceptance Criteria
-
-| Criterion | Status |
-|----------|--------|
-| Each finding includes severity, description, impact, and recommendation | Met |
-| Findings classified by severity (Critical / High / Medium / Low / Informational) | Met |
-| Indication of related open issues | Met (none existing) |
-| Claim flow diagram with failure points | Met |
-
----
-
-## Scout Findings
-
-Results from static analysis with [Scout](https://github.com/stellar/scout) (Soroban security linter):
-
-| Scout Severity | Location | Description | Correlation |
-|----------------|----------|-------------|-------------|
-| **CRITICAL** | `vault.rs:171` | Overflow/underflow in `token_balance * (100 + roi_percentage) / 100` | F-02 |
-| **CRITICAL** | `vault.rs:198` | Overflow in `total_redeemed + token_balance` | F-05 |
-| **CRITICAL** | `vault.rs:303` | Overflow/underflow in `preview_claim` formula | F-02 |
-| **CRITICAL** | `vault.rs:308` | Underflow in `usdc_amount - token_balance` | F-06 |
-| **MEDIUM** | `vault.rs:142` | Unsafe `expect` — Enabled flag | F-04 |
-| **MEDIUM** | `vault.rs:152` | Unsafe `expect` — ROI percentage | F-04 |
-| **MEDIUM** | `vault.rs:158` | Unsafe `expect` — Token address | F-04 |
-| **MEDIUM** | `vault.rs:173` | Unsafe `expect` — USDC address | F-04 |
-| **MEDIUM** | `vault.rs:96` | Admin parameter unnecessary; retrieve from storage | F-09 |
-| **ENHANCEMENT** | — | Soroban version: 23.1.1 → 25.3.0 | New |
-
-### Scout Summary
-
-```
-+----------------+----------+----------+--------+-------+-------------+
-| Crate | Status | Critical | Medium | Minor | Enhancement |
-+----------------+----------+----------+--------+-------+-------------+
-| vault_contract | Analyzed | 4 | 5 | 0 | 1 |
-+----------------+----------+----------+--------+-------+-------------+
-```
-
-### F-17: Outdated Soroban Version (Scout)
-
-| Field | Details |
-|-------|---------|
-| **Severity** | Enhancement |
-| **Description** | The project uses Soroban 23.1.1. The latest version is 25.3.0. Scout: `#[warn(soroban_version)]`. |
-| **Impact** | Possible missing security patches and SDK improvements in the latest versions. |
-| **Recommendation** | Update the `soroban-sdk` dependency in the workspace to 25.3.0 (or latest stable). Review changelog for breaking changes. |
-
----
-
-## References
-
-- [Soroban Security Best Practices — Stellar Developers](https://developers.stellar.org/docs/smart-contracts/guides/security)
-- [Soroban SDK — Storage, TTL and Archiving](https://developers.stellar.org/docs/smart-contracts/guides/storage)
-- [Soroban SDK — Authorization and require_auth](https://developers.stellar.org/docs/smart-contracts/guides/authorization)
-- [Soroban SDK — Events with #[contractevent]](https://developers.stellar.org/docs/smart-contracts/guides/events)
-- [Rust — Checked arithmetic: checked_*, saturating_*, wrapping_*](https://doc.rust-lang.org/std/primitive.i128.html#method.checked_add)
-- [OWASP Smart Contract Top 10](https://owasp.org/www-project-smart-contract-top-10/)
-- [SWC Registry — Smart Contract Weakness Classification](https://swcregistry.io/)
diff --git a/docs/plans/2026-03-13-update-roi-percentage.md b/docs/plans/2026-03-13-update-roi-percentage.md
deleted file mode 100644
index 2b14878..0000000
--- a/docs/plans/2026-03-13-update-roi-percentage.md
+++ /dev/null
@@ -1,398 +0,0 @@
-# Update ROI Percentage — Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Allow backoffice admins to update the ROI percentage of a campaign's vault from the `/roi` page via an actions dropdown menu.
-
-**Architecture:** Add a dropdown-menu UI component, an API call function, a hook (following `useToggleVault` pattern), and a dialog (following `FundRoiDialog` pattern). Replace inline action buttons in the ROI table row with a dropdown menu that groups all actions.
-
-**Tech Stack:** React 19, Next.js 16, Radix UI (dropdown-menu), TanStack React Query, Soroban wallet signing, sonner toasts.
-
----
-
-### Task 1: Add DropdownMenu UI component
-
-**Files:**
-- Create: `packages/ui/src/dropdown-menu.tsx`
-
-**Step 1: Install Radix dropdown-menu dependency**
-
-Run from repo root:
-```bash
-cd packages/ui && npm install @radix-ui/react-dropdown-menu
-```
-
-**Step 2: Create the component**
-
-Create `packages/ui/src/dropdown-menu.tsx` exporting Radix primitives styled consistently with the existing dialog/popover components. Follow the same pattern as `packages/ui/src/dialog.tsx` — thin wrappers around Radix with Tailwind classes.
-
-Exports needed:
-- `DropdownMenu` (Root)
-- `DropdownMenuTrigger`
-- `DropdownMenuContent`
-- `DropdownMenuItem`
-- `DropdownMenuSeparator`
-
-**Step 3: Export from package index**
-
-Verify the package uses path-based exports (e.g., `@tokenization/ui/dropdown-menu`). Check `packages/ui/package.json` exports field and add entry if needed.
-
-**Step 4: Verify build**
-
-Run: `npx turbo run build --filter=@tokenization/ui`
-Expected: BUILD SUCCESS
-
-**Step 5: Commit**
-
-```bash
-git add packages/ui/src/dropdown-menu.tsx packages/ui/package.json
-git commit -m "feat(ui): add DropdownMenu component"
-```
-
----
-
-### Task 2: Add API call function
-
-**Files:**
-- Modify: `apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts`
-
-**Step 1: Add `updateRoiPorcentage` function**
-
-Add to the bottom of `campaigns.api.ts`:
-
-```typescript
-export async function updateRoiPorcentage(params: {
- contractId: string;
- newRoiPorcentage: number;
- callerPublicKey: string;
-}): Promise<{ unsignedXdr: string }> {
- const { data } = await httpClient.post<{ unsignedXdr: string }>(
- "/vault/update-roi-porcentage",
- params,
- );
- return data;
-}
-```
-
-This calls the existing backend endpoint `POST /vault/update-roi-porcentage` which accepts `{ contractId, newRoiPorcentage, callerPublicKey }` and returns `{ unsignedXdr }`.
-
-**Step 2: Commit**
-
-```bash
-git add apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts
-git commit -m "feat: add updateRoiPorcentage API call"
-```
-
----
-
-### Task 3: Add `useUpdateRoiPercentage` hook
-
-**Files:**
-- Create: `apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts`
-
-**Step 1: Create the hook**
-
-Follow the exact pattern of `useToggleVault.ts`:
-
-```typescript
-"use client";
-
-import { useState } from "react";
-import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
-import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit";
-import { submitAndExtractAddress } from "@/features/campaigns/services/soroban.service";
-import { updateRoiPorcentage } from "@/features/campaigns/services/campaigns.api";
-
-interface UseUpdateRoiPercentageParams {
- onSuccess?: () => void;
-}
-
-export function useUpdateRoiPercentage({ onSuccess }: UseUpdateRoiPercentageParams = {}) {
- const { walletAddress } = useWalletContext();
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [error, setError] = useState(null);
-
- const execute = async (vaultContractId: string, newRoiPorcentage: number) => {
- if (!walletAddress) {
- setError("Wallet not connected");
- return;
- }
-
- setError(null);
- setIsSubmitting(true);
-
- try {
- const { unsignedXdr } = await updateRoiPorcentage({
- contractId: vaultContractId,
- newRoiPorcentage,
- callerPublicKey: walletAddress,
- });
-
- const signedXdr = await signTransaction({
- unsignedTransaction: unsignedXdr,
- address: walletAddress,
- });
-
- await submitAndExtractAddress(signedXdr);
-
- onSuccess?.();
- } catch (e) {
- const message = e instanceof Error ? e.message : "Unexpected error";
- setError(message);
- } finally {
- setIsSubmitting(false);
- }
- };
-
- return { execute, isSubmitting, error };
-}
-```
-
-**Step 2: Commit**
-
-```bash
-git add apps/backoffice-tokenization/src/features/campaigns/hooks/useUpdateRoiPercentage.ts
-git commit -m "feat: add useUpdateRoiPercentage hook"
-```
-
----
-
-### Task 4: Add `UpdateRoiDialog` component
-
-**Files:**
-- Create: `apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx`
-
-**Step 1: Create the dialog**
-
-Follow the exact pattern of `FundRoiDialog.tsx`:
-
-```typescript
-"use client";
-
-import { useState } from "react";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@tokenization/ui/dialog";
-import { Button } from "@tokenization/ui/button";
-import { Input } from "@tokenization/ui/input";
-import { Label } from "@tokenization/ui/label";
-import { Loader2 } from "lucide-react";
-import { useUpdateRoiPercentage } from "@/features/campaigns/hooks/useUpdateRoiPercentage";
-import { toast } from "sonner";
-
-interface UpdateRoiDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- campaignName: string;
- vaultId: string;
- onUpdated: () => void;
-}
-
-export function UpdateRoiDialog({
- open,
- onOpenChange,
- campaignName,
- vaultId,
- onUpdated,
-}: UpdateRoiDialogProps) {
- const [percentage, setPercentage] = useState("");
-
- const { execute, isSubmitting, error } = useUpdateRoiPercentage({
- onSuccess: () => {
- toast.success("ROI percentage updated successfully");
- onUpdated();
- onOpenChange(false);
- setPercentage("");
- },
- });
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- const parsed = Number(percentage);
- if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) return;
- execute(vaultId, parsed);
- };
-
- return (
-
-
-
- Update ROI Percentage — {campaignName}
-
- Set a new ROI percentage for this campaign's vault. Value must be between 0 and 100.
-
-
-
-
-
-
- );
-}
-```
-
-**Step 2: Commit**
-
-```bash
-git add apps/backoffice-tokenization/src/features/campaigns/components/roi/UpdateRoiDialog.tsx
-git commit -m "feat: add UpdateRoiDialog component"
-```
-
----
-
-### Task 5: Replace inline buttons with actions dropdown in ROI table row
-
-**Files:**
-- Modify: `apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-table-row.tsx`
-- Modify: `apps/backoffice-tokenization/src/features/campaigns/components/roi/types.ts`
-
-**Step 1: Update types to support the new callback**
-
-Add `onUpdateRoi` callback to `RoiTableRowProps` in `types.ts`:
-
-```typescript
-export interface RoiTableRowProps {
- campaign: Campaign;
- balance: number;
- onAddFunds: (campaign: Campaign) => void;
- onUpdateRoi: (campaign: Campaign) => void;
-}
-```
-
-Also add to `RoiTableProps`:
-
-```typescript
-export interface RoiTableProps {
- campaigns: Campaign[];
- onAddFunds: (campaign: Campaign) => void;
- onUpdateRoi: (campaign: Campaign) => void;
-}
-```
-
-**Step 2: Refactor `roi-table-row.tsx`**
-
-Replace the inline buttons in the `` with a `DropdownMenu`. The dropdown trigger is an "Actions" button with a `MoreHorizontal` icon. Menu items:
-- "Gestionar Prestamos" (link to loans page)
-- "Toggle Vault" (enable/disable)
-- "Subir Fondos" (calls `onAddFunds`)
-- Separator
-- "Actualizar ROI" (calls `onUpdateRoi`)
-
-Key imports to add:
-```typescript
-import { MoreHorizontal } from "lucide-react";
-import {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
-} from "@tokenization/ui/dropdown-menu";
-```
-
-**Step 3: Pass `onUpdateRoi` through `roi-table.tsx`**
-
-Update `RoiTable` to accept and forward `onUpdateRoi` prop to each `RoiTableRow`.
-
-**Step 4: Verify build**
-
-Run: `npx turbo run build --filter=backoffice-tokenization`
-Expected: BUILD SUCCESS
-
-**Step 5: Commit**
-
-```bash
-git add apps/backoffice-tokenization/src/features/campaigns/components/roi/
-git commit -m "feat: replace inline buttons with actions dropdown in ROI table"
-```
-
----
-
-### Task 6: Wire up UpdateRoiDialog in roi-view.tsx
-
-**Files:**
-- Modify: `apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-view.tsx`
-- Modify: `apps/backoffice-tokenization/src/features/campaigns/hooks/use-roi.ts`
-
-**Step 1: Add ROI dialog state to `use-roi.ts` hook**
-
-Add state management for the Update ROI dialog following the same pattern as `fundsDialogCampaign`:
-
-```typescript
-const [roiDialogCampaign, setRoiDialogCampaign] = useState(null);
-const [roiDialogOpen, setRoiDialogOpen] = useState(false);
-
-function openRoiDialog(campaign: Campaign) {
- setRoiDialogCampaign(campaign);
- setRoiDialogOpen(true);
-}
-
-function closeRoiDialog() {
- setRoiDialogOpen(false);
- setRoiDialogCampaign(null);
-}
-```
-
-Return all new values from the hook.
-
-**Step 2: Update `roi-view.tsx`**
-
-- Import `UpdateRoiDialog`
-- Destructure `roiDialogCampaign`, `roiDialogOpen`, `openRoiDialog`, `closeRoiDialog` from `useRoi()`
-- Pass `onUpdateRoi={openRoiDialog}` to ``
-- Render `` alongside `` in the dialogs section
-
-**Step 3: Verify build**
-
-Run: `npx turbo run build --filter=backoffice-tokenization`
-Expected: BUILD SUCCESS
-
-**Step 4: Commit**
-
-```bash
-git add apps/backoffice-tokenization/src/features/campaigns/components/roi/ apps/backoffice-tokenization/src/features/campaigns/hooks/use-roi.ts
-git commit -m "feat: wire UpdateRoiDialog into ROI view"
-```
diff --git a/package-lock.json b/package-lock.json
index f14f89d..61442a6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10449,6 +10449,10 @@
"resolved": "packages/config",
"link": true
},
+ "node_modules/@tokenization/features": {
+ "resolved": "packages/features",
+ "link": true
+ },
"node_modules/@tokenization/shared": {
"resolved": "packages/shared",
"link": true
@@ -26361,9 +26365,24 @@
"name": "@tokenization/config",
"version": "0.0.0"
},
+ "packages/features": {
+ "name": "@tokenization/features",
+ "version": "0.0.0",
+ "dependencies": {
+ "@tokenization/shared": "*",
+ "@tokenization/ui": "*",
+ "react": "19.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.6"
+ }
+ },
"packages/shared": {
"name": "@tokenization/shared",
- "version": "0.0.0"
+ "version": "0.0.0",
+ "dependencies": {
+ "@stellar/stellar-sdk": "^14.3.3"
+ }
},
"packages/tw-blocks-shared": {
"name": "@tokenization/tw-blocks-shared",
@@ -26373,7 +26392,8 @@
"name": "@tokenization/ui",
"version": "0.0.0",
"dependencies": {
- "@radix-ui/react-dropdown-menu": "^2.1.16"
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@tokenization/shared": "*"
}
}
}
diff --git a/packages/features/package.json b/packages/features/package.json
new file mode 100644
index 0000000..73bf4f7
--- /dev/null
+++ b/packages/features/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@tokenization/features",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./campaign": "./src/campaign/index.ts"
+ },
+ "dependencies": {
+ "@tokenization/shared": "*",
+ "@tokenization/ui": "*",
+ "react": "19.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.6"
+ }
+}
diff --git a/packages/features/src/campaign/campaign-toolbar.tsx b/packages/features/src/campaign/campaign-toolbar.tsx
new file mode 100644
index 0000000..d6b00a5
--- /dev/null
+++ b/packages/features/src/campaign/campaign-toolbar.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import type { SharedCampaignStatus } from "@tokenization/shared/src/types/campaign";
+
+const STATUS_OPTIONS: { value: SharedCampaignStatus | "all"; labelKey: string }[] = [
+ { value: "all", labelKey: "filterAll" },
+ { value: "FUNDRAISING", labelKey: "filterFundraising" },
+ { value: "ACTIVE", labelKey: "filterActive" },
+ { value: "REPAYMENT", labelKey: "filterRepayment" },
+ { value: "CLAIMABLE", labelKey: "filterClaimable" },
+ { value: "CLOSED", labelKey: "filterClosed" },
+];
+
+export interface CampaignToolbarLabels {
+ searchPlaceholder: string;
+ filterAll: string;
+ filterFundraising: string;
+ filterActive: string;
+ filterRepayment: string;
+ filterClaimable: string;
+ filterClosed: string;
+}
+
+export interface CampaignToolbarProps {
+ searchValue: string;
+ onSearchChange: (value: string) => void;
+ filterValue: SharedCampaignStatus | "all";
+ onFilterChange: (value: SharedCampaignStatus | "all") => void;
+ labels: CampaignToolbarLabels;
+}
+
+export function CampaignToolbar({
+ searchValue,
+ onSearchChange,
+ filterValue,
+ onFilterChange,
+ labels,
+}: CampaignToolbarProps) {
+ const labelByKey: Record = {
+ filterAll: labels.filterAll,
+ filterFundraising: labels.filterFundraising,
+ filterActive: labels.filterActive,
+ filterRepayment: labels.filterRepayment,
+ filterClaimable: labels.filterClaimable,
+ filterClosed: labels.filterClosed,
+ };
+
+ return (
+
+
+ {STATUS_OPTIONS.map((option) => (
+
+ ))}
+
+
+
+
+
onSearchChange(e.target.value)}
+ className="h-10 w-full rounded-xl border border-border bg-card pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+
+
+
+ );
+}
diff --git a/packages/features/src/campaign/campaigns-view.tsx b/packages/features/src/campaign/campaigns-view.tsx
new file mode 100644
index 0000000..df842b3
--- /dev/null
+++ b/packages/features/src/campaign/campaigns-view.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { SectionTitle } from "@tokenization/ui/section-title";
+import type { SharedCampaign, SharedCampaignStatus } from "@tokenization/shared/src/types/campaign";
+import { CampaignToolbar } from "./campaign-toolbar";
+import type { CampaignToolbarLabels } from "./campaign-toolbar";
+
+export interface SharedCampaignsViewProps {
+ title: string;
+ description: string;
+ campaigns: SharedCampaign[];
+ isLoading?: boolean;
+ loadingMessage?: string;
+ emptyMessage?: string;
+ headerActions?: React.ReactNode;
+ toolbarLabels: CampaignToolbarLabels;
+ children: (filteredCampaigns: SharedCampaign[]) => React.ReactNode;
+}
+
+export function SharedCampaignsView({
+ title,
+ description,
+ campaigns,
+ isLoading = false,
+ loadingMessage,
+ emptyMessage,
+ headerActions,
+ toolbarLabels,
+ children,
+}: SharedCampaignsViewProps) {
+ const [search, setSearch] = useState("");
+ const [filter, setFilter] = useState("all");
+
+ const filteredCampaigns = useMemo(() => {
+ return campaigns.filter((c) => {
+ const matchesStatus = filter === "all" || c.status === filter;
+ const matchesSearch =
+ search.trim() === "" ||
+ c.title.toLowerCase().includes(search.toLowerCase()) ||
+ c.description.toLowerCase().includes(search.toLowerCase());
+ return matchesStatus && matchesSearch;
+ });
+ }, [campaigns, search, filter]);
+
+ return (
+
+
+
+ {headerActions != null ?
{headerActions}
: null}
+
+
+ {isLoading ? (
+
+ {loadingMessage ?? ""}
+
+ ) : filteredCampaigns.length === 0 ? (
+
+ ) : (
+ children(filteredCampaigns)
+ )}
+
+ );
+}
diff --git a/packages/features/src/campaign/index.ts b/packages/features/src/campaign/index.ts
new file mode 100644
index 0000000..45c1c92
--- /dev/null
+++ b/packages/features/src/campaign/index.ts
@@ -0,0 +1,2 @@
+export * from "./campaign-toolbar";
+export * from "./campaigns-view";
diff --git a/packages/features/src/index.ts b/packages/features/src/index.ts
new file mode 100644
index 0000000..d662da8
--- /dev/null
+++ b/packages/features/src/index.ts
@@ -0,0 +1 @@
+export * from "./campaign";
diff --git a/packages/features/tsconfig.json b/packages/features/tsconfig.json
new file mode 100644
index 0000000..64d9c1f
--- /dev/null
+++ b/packages/features/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2019",
+ "lib": ["ES2019", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 70bd35a..e7278ac 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -7,6 +7,15 @@
".": "./src/index.ts",
"./lib/utils": "./src/lib/utils.ts",
"./lib/sendTransactionService": "./src/lib/sendTransactionService.ts",
- "./lib/httpClient": "./src/lib/httpClient.ts"
+ "./lib/httpClient": "./src/lib/httpClient.ts",
+ "./lib/sorobanClient": "./src/lib/sorobanClient.ts",
+ "./lib/sorobanSubmitAndWait": "./src/lib/sorobanSubmitAndWait.ts",
+ "./lib/contractErrorHandler": "./src/lib/contractErrorHandler.ts",
+ "./lib/vaultDeploymentService": "./src/lib/vaultDeploymentService.ts",
+ "./lib/constants": "./src/lib/constants.ts",
+ "./src/types/campaign": "./src/types/campaign.ts"
+ },
+ "dependencies": {
+ "@stellar/stellar-sdk": "^14.3.3"
}
}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index d61b66c..476ce60 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,3 +1,8 @@
export * from "./lib/utils";
export * from "./lib/sendTransactionService";
export * from "./lib/httpClient";
+export * from "./lib/sorobanClient";
+export * from "./lib/sorobanSubmitAndWait";
+export * from "./lib/contractErrorHandler";
+export * from "./lib/constants";
+export * from "./lib/campaignStatus";
diff --git a/apps/backoffice-tokenization/src/features/campaigns/constants/campaign-status.ts b/packages/shared/src/lib/campaignStatus.ts
similarity index 64%
rename from apps/backoffice-tokenization/src/features/campaigns/constants/campaign-status.ts
rename to packages/shared/src/lib/campaignStatus.ts
index 1a413bf..ff82627 100644
--- a/apps/backoffice-tokenization/src/features/campaigns/constants/campaign-status.ts
+++ b/packages/shared/src/lib/campaignStatus.ts
@@ -1,8 +1,15 @@
-import type { CampaignStatus } from "@/features/campaigns/types/campaign.types";
+import type { SharedCampaignStatus } from "../types/campaign";
+export type CampaignStatusConfig = { label: string; className: string };
+
+/**
+ * Returns a consistent status config for campaign badges (labels + Tailwind classes).
+ * Pass your campaigns namespace translator, e.g. useTranslations("campaigns").
+ * Translation keys: status.DRAFT, status.FUNDRAISING, status.ACTIVE, etc.
+ */
export function getCampaignStatusConfig(
t: (key: string) => string,
-): Record {
+): Record {
return {
DRAFT: { label: t("status.DRAFT"), className: "bg-secondary text-text-muted border-border" },
FUNDRAISING: { label: t("status.FUNDRAISING"), className: "bg-blue-50 text-blue-600 border-blue-200" },
diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts
new file mode 100644
index 0000000..440d236
--- /dev/null
+++ b/packages/shared/src/lib/constants.ts
@@ -0,0 +1,28 @@
+const USDC_ADDRESS =
+ process.env.NEXT_PUBLIC_DEFAULT_USDC_ADDRESS ||
+ "";
+
+const SOROBAN_RPC_URL =
+ process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ||
+ "";
+
+const NETWORK_PASSPHRASE =
+ process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ||
+ "";
+
+const ESCROW_EXPLORER_URL = "https://viewer.trustlesswork.com/";
+
+function getContractExplorerUrl(contractId: string): string {
+ const base = NETWORK_PASSPHRASE?.toLowerCase().includes("test")
+ ? "https://stellar.expert/explorer/testnet/contract"
+ : "https://stellar.expert/explorer/public/contract";
+ return `${base}/${contractId}`;
+}
+
+export {
+ USDC_ADDRESS,
+ SOROBAN_RPC_URL,
+ NETWORK_PASSPHRASE,
+ ESCROW_EXPLORER_URL,
+ getContractExplorerUrl,
+};
\ No newline at end of file
diff --git a/apps/backoffice-tokenization/src/lib/contractErrorHandler.ts b/packages/shared/src/lib/contractErrorHandler.ts
similarity index 96%
rename from apps/backoffice-tokenization/src/lib/contractErrorHandler.ts
rename to packages/shared/src/lib/contractErrorHandler.ts
index 84467a8..08d32f8 100644
--- a/apps/backoffice-tokenization/src/lib/contractErrorHandler.ts
+++ b/packages/shared/src/lib/contractErrorHandler.ts
@@ -66,6 +66,11 @@ const CONTEXT_TO_TRANSLATION_KEY: Record = {
"token-sale": "tokenSale",
};
+export type ContractErrorTranslationFn = (
+ key: string,
+ values?: Record,
+) => string;
+
/**
* Extracts and maps contract error codes to user-friendly messages.
*
@@ -80,7 +85,7 @@ const CONTEXT_TO_TRANSLATION_KEY: Record = {
export function extractContractError(
error: unknown,
context?: "vault" | "token-sale",
- t?: (key: string, values?: Record) => string,
+ t?: ContractErrorTranslationFn,
): {
message: string;
details: string;
diff --git a/packages/shared/src/lib/sendTransactionService.ts b/packages/shared/src/lib/sendTransactionService.ts
index 22e2ae7..1d0c879 100644
--- a/packages/shared/src/lib/sendTransactionService.ts
+++ b/packages/shared/src/lib/sendTransactionService.ts
@@ -11,9 +11,7 @@ export type SendTransactionResponse = {
};
export type SendTransactionServiceOptions = {
- /** Core API base URL (e.g. http://localhost:4000). When not set, uses NEXT_PUBLIC_CORE_API_URL or fallback "/api". */
baseURL?: string;
- /** API key for core API (x-api-key). Required when baseURL points to core. */
apiKey?: string;
};
@@ -31,17 +29,13 @@ export class SendTransactionService {
const env =
typeof process !== "undefined" ? process.env : ({} as NodeJS.ProcessEnv);
- // Prefer explicit option, then server-side secrets, then public envs
let apiKey =
options.apiKey?.trim() ||
- // When running on the server (Next.js SSR / route handlers), prefer
- // the same secrets that the Core API uses in its ApiKeyGuard
(typeof window === "undefined"
? env.BACKOFFICE_API_KEY?.trim() ||
- env.INVESTORS_API_KEY?.trim() ||
- ""
+ env.INVESTORS_API_KEY?.trim() ||
+ ""
: "") ||
- // Fallback to public envs for purely browser-side usage
env.NEXT_PUBLIC_API_KEY?.trim() ||
env.NEXT_PUBLIC_INVESTORS_API_KEY?.trim() ||
env.NEXT_PUBLIC_BACKOFFICE_API_KEY?.trim() ||
diff --git a/apps/backoffice-tokenization/src/lib/sorobanClient.ts b/packages/shared/src/lib/sorobanClient.ts
similarity index 80%
rename from apps/backoffice-tokenization/src/lib/sorobanClient.ts
rename to packages/shared/src/lib/sorobanClient.ts
index 4ba7492..c4f104b 100644
--- a/apps/backoffice-tokenization/src/lib/sorobanClient.ts
+++ b/packages/shared/src/lib/sorobanClient.ts
@@ -174,8 +174,8 @@ export class SorobanClient {
// The best we can do is throw a helpful error
throw new Error(
`Cannot determine address for existing contract. ` +
- `Contracts are already deployed for this escrowContractId. ` +
- `Please use a different escrowContractId or check if the contracts are already deployed.`
+ `Contracts are already deployed for this escrowContractId. ` +
+ `Please use a different escrowContractId or check if the contracts are already deployed.`,
);
}
@@ -211,37 +211,49 @@ export class SorobanClient {
return Address.fromScVal(result.returnValue).toString();
} catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
const errorStr = errorMessage.toLowerCase();
-
+
// Check for contract already exists error (can appear in different formats)
// Also check for the special marker we added in submitTransaction
// The error can be: "HostError: Error(Storage, ExistingValue)" or "contract already exists"
- const isExistingContractError =
+ const isExistingContractError =
errorMessage.includes("CONTRACT_ALREADY_EXISTS") ||
- errorStr.includes("contract already exists") ||
+ errorStr.includes("contract already exists") ||
errorStr.includes("existingvalue") ||
(errorStr.includes("storage") && errorStr.includes("existing")) ||
- (errorStr.includes("hosterror") && errorStr.includes("storage") && errorStr.includes("existing"));
-
+ (errorStr.includes("hosterror") &&
+ errorStr.includes("storage") &&
+ errorStr.includes("existing"));
+
if (isExistingContractError) {
- console.log(`${label} already exists, attempting to get address via simulation...`);
+ console.log(
+ `${label} already exists, attempting to get address via simulation...`,
+ );
// Try to get the address by simulating with the same parameters
try {
- return await this.simulateContractCreation(wasmHash, salt, constructorArgs);
+ return await this.simulateContractCreation(
+ wasmHash,
+ salt,
+ constructorArgs,
+ );
} catch (simError) {
- const simErrorMsg = simError instanceof Error ? simError.message : String(simError);
+ const simErrorMsg =
+ simError instanceof Error ? simError.message : String(simError);
const simErrorStr = simErrorMsg.toLowerCase();
// If simulation also fails with same error, the contract definitely exists
// We can't easily get its address, so suggest using deploymentId
- if (simErrorStr.includes("contract already exists") ||
- simErrorStr.includes("existingvalue") ||
- (simErrorStr.includes("storage") && simErrorStr.includes("existing"))) {
+ if (
+ simErrorStr.includes("contract already exists") ||
+ simErrorStr.includes("existingvalue") ||
+ (simErrorStr.includes("storage") && simErrorStr.includes("existing"))
+ ) {
throw new Error(
`Contracts are already deployed for this escrowContractId. ` +
- `To redeploy, please provide a 'deploymentId' parameter in your request ` +
- `(e.g., {"deploymentId": "v2"}) to create unique contract addresses. ` +
- `Alternatively, use a different escrowContractId.`
+ `To redeploy, please provide a 'deploymentId' parameter in your request ` +
+ `(e.g., {"deploymentId": "v2"}) to create unique contract addresses. ` +
+ `Alternatively, use a different escrowContractId.`,
);
}
// If simulation fails for other reason, throw original error
@@ -260,8 +272,10 @@ export class SorobanClient {
salt: Buffer,
constructorArgs: ScVal[],
): Promise {
- const account = (await this.server.getAccount(this.publicKey)) as AccountLike;
-
+ const account = (await this.server.getAccount(
+ this.publicKey,
+ )) as AccountLike;
+
const transaction = this.buildBaseTx(account)
.addOperation(
Operation.createCustomContract({
@@ -281,7 +295,10 @@ export class SorobanClient {
if ("error" in simulation) {
const errorStr = JSON.stringify(simulation.error);
// If contract already exists, try with empty args to get address
- if (errorStr.includes("contract already exists") || errorStr.includes("ExistingValue")) {
+ if (
+ errorStr.includes("contract already exists") ||
+ errorStr.includes("ExistingValue")
+ ) {
return this.getContractAddressFromSalt(wasmHash, salt);
}
throw new Error(`Simulation failed: ${errorStr}`);
@@ -305,22 +322,25 @@ export class SorobanClient {
const tx = buildTx(account);
const preparedTx = await this.server.prepareTransaction(tx);
preparedTx.sign(this.keypair);
-
+
let sendResponse;
try {
sendResponse = await this.server.sendTransaction(preparedTx);
} catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
const errorStr = errorMessage.toLowerCase();
// Check if this is a "contract already exists" error from sendTransaction
- if (errorStr.includes("existingvalue") ||
- errorStr.includes("contract already exists") ||
- (errorStr.includes("storage") && errorStr.includes("existing"))) {
+ if (
+ errorStr.includes("existingvalue") ||
+ errorStr.includes("contract already exists") ||
+ (errorStr.includes("storage") && errorStr.includes("existing"))
+ ) {
throw new Error(`CONTRACT_ALREADY_EXISTS: ${errorMessage}`);
}
throw error;
}
-
+
const result = await this.waitForTransaction(sendResponse.hash, label);
if (result.status !== "SUCCESS") {
@@ -331,25 +351,25 @@ export class SorobanClient {
if (result.resultXdr) {
errorDetails = String(result.resultXdr);
}
-
+
// Also check if there's error information in the result object itself
const resultStr = JSON.stringify(result);
const errorMessage = `${label} failed: ${errorDetails}`;
-
+
// Check if this is a "contract already exists" error
// The error can appear as: "Error(Storage, ExistingValue)" or "contract already exists"
// Check both errorDetails and the full result string
const errorStr = (errorDetails + " " + resultStr).toLowerCase();
- const isExistingContractError =
- errorStr.includes("existingvalue") ||
+ const isExistingContractError =
+ errorStr.includes("existingvalue") ||
errorStr.includes("contract already exists") ||
(errorStr.includes("storage") && errorStr.includes("existing")) ||
(errorStr.includes("hosterror") && errorStr.includes("storage"));
-
+
if (isExistingContractError) {
throw new Error(`CONTRACT_ALREADY_EXISTS: ${errorMessage}`);
}
-
+
throw new Error(errorMessage);
}
@@ -361,17 +381,21 @@ export class SorobanClient {
for (let attempt = 0; attempt < this.config.maxAttempts; attempt += 1) {
try {
const txResult = await this.server.getTransaction(hash);
-
+
if (txResult.status === "SUCCESS" || txResult.status === "FAILED") {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
- console.log(`${label} completed after ${elapsed}s (attempt ${attempt + 1})`);
+ console.log(
+ `${label} completed after ${elapsed}s (attempt ${attempt + 1})`,
+ );
return txResult;
}
-
+
// Log progress every 10 attempts
if (attempt > 0 && attempt % 10 === 0) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
- console.log(`${label} still pending... (${elapsed}s elapsed, attempt ${attempt + 1}/${this.config.maxAttempts})`);
+ console.log(
+ `${label} still pending... (${elapsed}s elapsed, attempt ${attempt + 1}/${this.config.maxAttempts})`,
+ );
}
} catch (error) {
// If transaction not found, continue polling (it might not be included yet)
@@ -379,10 +403,13 @@ export class SorobanClient {
// This is expected during early polling, continue
} else {
// Log unexpected errors but continue polling
- console.warn(`${label} polling error (attempt ${attempt + 1}):`, error instanceof Error ? error.message : String(error));
+ console.warn(
+ `${label} polling error (attempt ${attempt + 1}):`,
+ error instanceof Error ? error.message : String(error),
+ );
}
}
-
+
// Continue polling while the transaction is not yet finalized on chain
// Some RPCs may report PENDING or NOT_FOUND until the transaction is included
await new Promise((resolve) =>
@@ -391,12 +418,15 @@ export class SorobanClient {
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
- const maxWaitTime = ((this.config.maxAttempts * this.config.pollDelayMs) / 1000).toFixed(1);
+ const maxWaitTime = (
+ (this.config.maxAttempts * this.config.pollDelayMs) /
+ 1000
+ ).toFixed(1);
throw new Error(
`${label} timeout after ${elapsed}s (max wait: ${maxWaitTime}s). ` +
- `Transaction hash: ${hash}. ` +
- `The transaction may still be processing on the network. ` +
- `Please check the transaction status manually or try again later.`
+ `Transaction hash: ${hash}. ` +
+ `The transaction may still be processing on the network. ` +
+ `Please check the transaction status manually or try again later.`,
);
}
}
diff --git a/packages/shared/src/lib/sorobanSubmitAndWait.ts b/packages/shared/src/lib/sorobanSubmitAndWait.ts
new file mode 100644
index 0000000..a0094ff
--- /dev/null
+++ b/packages/shared/src/lib/sorobanSubmitAndWait.ts
@@ -0,0 +1,48 @@
+import { rpc, TransactionBuilder, Networks } from "@stellar/stellar-sdk";
+
+const DEFAULT_POLL_ATTEMPTS = 30;
+const DEFAULT_POLL_DELAY_MS = 2000;
+
+export type SubmitSignedTransactionAndWaitOptions = {
+ rpcUrl: string;
+ networkPassphrase?: string;
+ pollAttempts?: number;
+ pollDelayMs?: number;
+};
+
+/**
+ * Submits a signed transaction XDR to the Soroban RPC and polls until the
+ * transaction is finalized (SUCCESS or FAILED). Throws on send error or timeout.
+ */
+export async function submitSignedTransactionAndWait(
+ signedXdr: string,
+ options: SubmitSignedTransactionAndWaitOptions,
+): Promise {
+ const {
+ rpcUrl,
+ networkPassphrase = Networks.TESTNET,
+ pollAttempts = DEFAULT_POLL_ATTEMPTS,
+ pollDelayMs = DEFAULT_POLL_DELAY_MS,
+ } = options;
+
+ const server = new rpc.Server(rpcUrl);
+ const tx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase);
+
+ const send = await server.sendTransaction(tx);
+ if (send.status === "ERROR") {
+ throw new Error(`Soroban error: ${JSON.stringify(send.errorResult)}`);
+ }
+
+ let result: rpc.Api.GetTransactionResponse | undefined;
+ for (let i = 0; i < pollAttempts; i++) {
+ await new Promise((r) => setTimeout(r, pollDelayMs));
+ result = await server.getTransaction(send.hash);
+ if (result.status !== rpc.Api.GetTransactionStatus.NOT_FOUND) break;
+ }
+
+ if (!result || result.status === rpc.Api.GetTransactionStatus.NOT_FOUND) {
+ throw new Error(`Transaction TIMEOUT`);
+ }
+
+ return result;
+}
diff --git a/packages/shared/src/lib/utils.ts b/packages/shared/src/lib/utils.ts
index 193828b..460a6aa 100644
--- a/packages/shared/src/lib/utils.ts
+++ b/packages/shared/src/lib/utils.ts
@@ -1,8 +1,32 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
+const ALLOWED_KEYS = ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"];
+const SOROBAN_DECIMAL_SCALE = 1e7;
+
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+export function fromStroops(stroops: number | string): number {
+ return Number(stroops) / SOROBAN_DECIMAL_SCALE;
+}
+import type { KeyboardEvent } from "react";
+
+export function numericInputKeyDown(e: KeyboardEvent) {
+ if (
+ !/[0-9.,]/.test(e.key) &&
+ !ALLOWED_KEYS.includes(e.key) &&
+ !e.ctrlKey &&
+ !e.metaKey
+ ) {
+ e.preventDefault();
+ }
+}
+
+export function parseNumericInput(value: string, max?: number): number {
+ const num = Number(value.replace(/[^0-9.,]/g, "").replace(",", "."));
+ const safe = isNaN(num) ? 0 : num;
+ return max !== undefined ? Math.min(safe, max) : safe;
+}
diff --git a/packages/shared/src/types/campaign.ts b/packages/shared/src/types/campaign.ts
new file mode 100644
index 0000000..acffd94
--- /dev/null
+++ b/packages/shared/src/types/campaign.ts
@@ -0,0 +1,22 @@
+export type SharedCampaignStatus =
+ | "DRAFT"
+ | "FUNDRAISING"
+ | "ACTIVE"
+ | "REPAYMENT"
+ | "CLAIMABLE"
+ | "CLOSED"
+ | "PAUSED";
+
+export interface SharedCampaign {
+ id: string;
+ title: string;
+ description: string;
+ status: SharedCampaignStatus;
+ loansCompleted: number;
+ investedAmount: number;
+ currency: string;
+ vaultId: string | null;
+ escrowId: string;
+ poolSize: number;
+}
+
diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/EscrowDetailDialog.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/EscrowDetailDialog.tsx
index c1dceac..6288086 100644
--- a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/EscrowDetailDialog.tsx
+++ b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/EscrowDetailDialog.tsx
@@ -20,13 +20,7 @@ import { Entities } from "./Entities";
import { GeneralInformation } from "./GeneralInformation";
import { useEscrowContext } from "@tokenization/tw-blocks-shared/src/providers/EscrowProvider";
import { SuccessReleaseDialog } from "./SuccessReleaseDialog";
-
-/**
- * Based on the provided roles -> https://docs.trustlesswork.com/trustless-work/technology-overview/roles-in-trustless-work
- *
- * The roles that the user assigns in the escrow initialization are in the userRolesInEscrow state. Based on these roles, you'll have different actions buttons.
- *
- */
+import { ESCROW_EXPLORER_URL } from "@tokenization/shared/lib/constants";
interface EscrowDetailDialogProps {
isDialogOpen: boolean;
@@ -55,7 +49,7 @@ export const EscrowDetailDialog = ({
selectedEscrow,
});
- const viewerUrl = `https://viewer.trustlesswork.com/${selectedEscrow?.contractId}`;
+ const viewerUrl = `${ESCROW_EXPLORER_URL}${selectedEscrow?.contractId}`;
if (!isDialogOpen || !selectedEscrow) return null;
return (
diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/GeneralInformation.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/GeneralInformation.tsx
index 5c13ffc..d88db9b 100644
--- a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/GeneralInformation.tsx
+++ b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/GeneralInformation.tsx
@@ -7,7 +7,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "@tokenization/ui/tooltip";
-import { cn } from "@/lib/utils";
+import { cn } from "@tokenization/shared/lib/utils";
import { MultiReleaseMilestone } from "@trustless-work/escrow";
import {
Ban,
diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/StatisticsCard.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/StatisticsCard.tsx
index 6327bcf..cd13034 100644
--- a/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/StatisticsCard.tsx
+++ b/packages/tw-blocks-shared/src/escrows/escrows-by-role/details/StatisticsCard.tsx
@@ -3,7 +3,7 @@
import React from "react";
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
-import { cn } from "@/lib/utils";
+import { cn } from "@tokenization/shared/lib/utils";
import { Button } from "@tokenization/ui/button";
import { Card, CardContent } from "@tokenization/ui/card";
import { Badge } from "@tokenization/ui/badge";
diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/EscrowDetailDialog.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/EscrowDetailDialog.tsx
index c1dceac..6288086 100644
--- a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/EscrowDetailDialog.tsx
+++ b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/EscrowDetailDialog.tsx
@@ -20,13 +20,7 @@ import { Entities } from "./Entities";
import { GeneralInformation } from "./GeneralInformation";
import { useEscrowContext } from "@tokenization/tw-blocks-shared/src/providers/EscrowProvider";
import { SuccessReleaseDialog } from "./SuccessReleaseDialog";
-
-/**
- * Based on the provided roles -> https://docs.trustlesswork.com/trustless-work/technology-overview/roles-in-trustless-work
- *
- * The roles that the user assigns in the escrow initialization are in the userRolesInEscrow state. Based on these roles, you'll have different actions buttons.
- *
- */
+import { ESCROW_EXPLORER_URL } from "@tokenization/shared/lib/constants";
interface EscrowDetailDialogProps {
isDialogOpen: boolean;
@@ -55,7 +49,7 @@ export const EscrowDetailDialog = ({
selectedEscrow,
});
- const viewerUrl = `https://viewer.trustlesswork.com/${selectedEscrow?.contractId}`;
+ const viewerUrl = `${ESCROW_EXPLORER_URL}${selectedEscrow?.contractId}`;
if (!isDialogOpen || !selectedEscrow) return null;
return (
diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/GeneralInformation.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/GeneralInformation.tsx
index 712cea8..8b58245 100644
--- a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/GeneralInformation.tsx
+++ b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/GeneralInformation.tsx
@@ -3,7 +3,7 @@
import React, { useMemo } from "react";
import { Card } from "@tokenization/ui/card";
import { Tooltip, TooltipTrigger, TooltipContent } from "@tokenization/ui/tooltip";
-import { cn } from "@/lib/utils";
+import { cn } from "@tokenization/shared/lib/utils";
import { MultiReleaseMilestone } from "@trustless-work/escrow";
import {
Ban,
diff --git a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/StatisticsCard.tsx b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/StatisticsCard.tsx
index 6327bcf..cd13034 100644
--- a/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/StatisticsCard.tsx
+++ b/packages/tw-blocks-shared/src/escrows/escrows-by-signer/details/StatisticsCard.tsx
@@ -3,7 +3,7 @@
import React from "react";
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
-import { cn } from "@/lib/utils";
+import { cn } from "@tokenization/shared/lib/utils";
import { Button } from "@tokenization/ui/button";
import { Card, CardContent } from "@tokenization/ui/card";
import { Badge } from "@tokenization/ui/badge";
diff --git a/packages/tw-blocks-shared/src/helpers/format.helper.ts b/packages/tw-blocks-shared/src/helpers/format.helper.ts
index 35f0c22..79b3d65 100644
--- a/packages/tw-blocks-shared/src/helpers/format.helper.ts
+++ b/packages/tw-blocks-shared/src/helpers/format.helper.ts
@@ -5,8 +5,11 @@
* @param currency - The currency
* @returns The formatted currency
*/
-export const formatCurrency = (value: number, currency: string) => {
- return `${currency} ${value.toFixed(2)}`;
+export const formatCurrency = (value: number | string, currency: string) => {
+ const numericValue = typeof value === "string" ? Number(value) : value;
+ const safeValue = Number.isFinite(numericValue) ? numericValue : 0;
+
+ return `${currency} ${safeValue.toFixed(2)}`;
};
/**
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 50412f9..d466484 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -38,9 +38,13 @@
"./app-sidebar": "./src/app-sidebar.tsx",
"./hooks/use-mobile": "./src/hooks/use-mobile.ts",
"./sidebar-wallet-button": "./src/sidebar-wallet-button.tsx",
- "./campaign-card": "./src/campaign-card.tsx"
+ "./campaign-card": "./src/campaign-card.tsx",
+ "./toast-with-tx": "./src/toast-with-tx.tsx",
+ "./language-switcher": "./src/language-switcher.tsx",
+ "./section-title": "./src/section-title.tsx"
},
"dependencies": {
- "@radix-ui/react-dropdown-menu": "^2.1.16"
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@tokenization/shared": "*"
}
}
diff --git a/packages/ui/src/app-sidebar.tsx b/packages/ui/src/app-sidebar.tsx
index c2e81f9..33703c8 100644
--- a/packages/ui/src/app-sidebar.tsx
+++ b/packages/ui/src/app-sidebar.tsx
@@ -47,6 +47,8 @@ export interface AppSidebarProps extends React.ComponentProps {
logo: AppSidebarLogoConfig
footerItems?: AppSidebarFooterItem[]
footerContent?: React.ReactNode
+ /** Pathname for active state (e.g. from next-intl usePathname). If not set, uses next/navigation usePathname(). */
+ pathname?: string
}
export function AppSidebar({
@@ -54,9 +56,11 @@ export function AppSidebar({
logo,
footerItems,
footerContent,
+ pathname: pathnameProp,
...sidebarProps
}: AppSidebarProps) {
- const pathname = usePathname()
+ const nextPathname = usePathname()
+ const pathname = pathnameProp ?? nextPathname
const logoHref = logo.href ?? "/"
return (
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 9117218..02fb895 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -26,5 +26,7 @@ export * from "./table";
export * from "./tabs";
export * from "./textarea";
export * from "./tooltip";
+export * from "./language-switcher";
+export * from "./section-title";
diff --git a/apps/backoffice-tokenization/src/components/shared/language-switcher.tsx b/packages/ui/src/language-switcher.tsx
similarity index 50%
rename from apps/backoffice-tokenization/src/components/shared/language-switcher.tsx
rename to packages/ui/src/language-switcher.tsx
index 9264917..1430f6d 100644
--- a/apps/backoffice-tokenization/src/components/shared/language-switcher.tsx
+++ b/packages/ui/src/language-switcher.tsx
@@ -1,22 +1,32 @@
"use client";
import { useLocale } from "next-intl";
-import { useRouter, usePathname } from "@/i18n/navigation";
-import { cn } from "@/lib/utils";
+import { cn } from "@tokenization/shared/lib/utils";
-const locales = [
- { code: "es" as const, label: "ES" },
- { code: "en" as const, label: "EN" },
-];
+export type LanguageSwitcherLocale = "es" | "en";
-export function LanguageSwitcher() {
+export interface LanguageSwitcherProps {
+ pathname: string;
+ onSwitchLocale: (next: LanguageSwitcherLocale) => void;
+ locales?: ReadonlyArray<{ code: LanguageSwitcherLocale; label: string }>;
+}
+
+const defaultLocales: ReadonlyArray<{ code: LanguageSwitcherLocale; label: string }> =
+ [
+ { code: "es", label: "ES" },
+ { code: "en", label: "EN" },
+ ];
+
+export function LanguageSwitcher({
+ pathname: _pathname,
+ onSwitchLocale,
+ locales = defaultLocales,
+}: LanguageSwitcherProps) {
const locale = useLocale();
- const router = useRouter();
- const pathname = usePathname();
- function switchLocale(next: "es" | "en") {
+ function switchLocale(next: LanguageSwitcherLocale) {
if (next === locale) return;
- router.replace(pathname, { locale: next });
+ onSwitchLocale(next);
}
return (
@@ -38,3 +48,4 @@ export function LanguageSwitcher() {
);
}
+
diff --git a/apps/investor-tokenization/src/components/shared/section-title.tsx b/packages/ui/src/section-title.tsx
similarity index 99%
rename from apps/investor-tokenization/src/components/shared/section-title.tsx
rename to packages/ui/src/section-title.tsx
index 2889b55..d94c853 100644
--- a/apps/investor-tokenization/src/components/shared/section-title.tsx
+++ b/packages/ui/src/section-title.tsx
@@ -13,3 +13,4 @@ export function SectionTitle({ title, description }: SectionTitleProps) {
);
}
+
diff --git a/apps/backoffice-tokenization/src/lib/toastWithTx.tsx b/packages/ui/src/toast-with-tx.tsx
similarity index 85%
rename from apps/backoffice-tokenization/src/lib/toastWithTx.tsx
rename to packages/ui/src/toast-with-tx.tsx
index c356d14..71592a2 100644
--- a/apps/backoffice-tokenization/src/lib/toastWithTx.tsx
+++ b/packages/ui/src/toast-with-tx.tsx
@@ -1,6 +1,8 @@
+"use client";
+
import { toast } from "sonner";
-import { Button } from "@tokenization/ui/button";
-import { Link } from "@/i18n/navigation";
+import { Button } from "./button";
+import Link from "next/link";
const EXPLORER_BASE = "https://stellar.expert/explorer/testnet";
@@ -22,6 +24,6 @@ export function toastSuccessWithTx(
),
- { duration: 5000 }
+ { duration: 5000 },
);
}