diff --git a/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx b/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx index 899c481..b73aa2a 100644 --- a/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx +++ b/apps/backoffice-tokenization/src/components/shared/campaign-card.tsx @@ -6,7 +6,7 @@ import { Badge } from "@tokenization/ui/badge"; import { Button } from "@tokenization/ui/button"; import { CampaignCard as SharedCampaignCard } from "@tokenization/ui/campaign-card"; import { cn } from "@tokenization/shared/lib/utils"; -import { Landmark } from "lucide-react"; +import { Banknote, CheckCircle, Circle, Landmark } from "lucide-react"; import { useGetEscrowFromIndexerByContractIds } from "@trustless-work/escrow"; import type { MultiReleaseMilestone } from "@trustless-work/escrow/types"; import type { Campaign } from "@/features/campaigns/types/campaign.types"; @@ -39,9 +39,12 @@ export function CampaignCard({ campaign }: CampaignCardProps) { staleTime: 1000 * 60 * 5, }); - const milestones = (escrowData?.milestones ?? []) as MultiReleaseMilestone[]; - const assigned = milestones.reduce((sum, m) => sum + fromStroops(m.amount ?? 0), 0); - const progressValue = campaign.poolSize > 0 ? Math.min(100, (assigned / campaign.poolSize) * 100) : 0; + const allMilestones = (escrowData?.milestones ?? []) as MultiReleaseMilestone[]; + const visibleMilestones = allMilestones.slice(1); + const assigned = allMilestones.reduce((sum, m) => sum + fromStroops(m.amount ?? 0), 0); + const loansCompleted = visibleMilestones.filter((m) => m.status === "Approved").length; + const totalLoans = visibleMilestones.length; + const progressValue = totalLoans > 0 ? Math.min(100, (loansCompleted / totalLoans) * 100) : 0; return ( - USDC {formatCurrency(assigned)} / USDC {formatCurrency(campaign.poolSize)} + Pool Size: USDC {formatCurrency(assigned)} / USDC {formatCurrency(campaign.poolSize)} - {campaign.vaultId ? ( - - Vault: {campaign.vaultId} - - ) : null} } - progress={{ label: "Dinero recaudado", value: progressValue }} - /> + progress={{ label: "Loans Completed", value: progressValue }} + > + {visibleMilestones.length > 0 ? ( + <> +

+ Loans +

+ + + ) : ( +

No loans available.

+ )} +
); } 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 2a6d87e..a4ff64b 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 @@ -245,18 +245,17 @@ export function ManageLoansView({ contractId }: ManageLoansViewProps) { milestones.map((milestone, index) => { const isApproved = milestone.flags?.approved === true; const isReleased = milestone.flags?.released === true; - const milestoneAmount = fromStroops(milestone.amount || 0); + const milestoneAmount = milestone.amount const insufficientFunds = escrowBalance < milestoneAmount; return (
{ clearFlowState(); router.push("/campaigns"); @@ -298,7 +302,7 @@ export function useCreateCampaign() { setPhaseStatus(currentPhase, "error", message); setDeployFailedAt(currentPhase); } - }, [walletAddress, router]); + }, [walletAddress, router, queryClient]); const retryDeploy = useCallback(() => { if (deployFailedAt === null) return; diff --git a/apps/backoffice-tokenization/src/features/campaigns/hooks/useToggleVault.ts b/apps/backoffice-tokenization/src/features/campaigns/hooks/useToggleVault.ts index afd5fce..97110fd 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/hooks/useToggleVault.ts +++ b/apps/backoffice-tokenization/src/features/campaigns/hooks/useToggleVault.ts @@ -1,10 +1,14 @@ "use client"; import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; 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 { enableVault } from "@/features/campaigns/services/campaigns.api"; +import { + enableVault, + updateCampaignStatusByVaultId, +} from "@/features/campaigns/services/campaigns.api"; interface UseToggleVaultParams { onSuccess?: () => void; @@ -12,6 +16,7 @@ interface UseToggleVaultParams { export function useToggleVault({ onSuccess }: UseToggleVaultParams = {}) { const { walletAddress } = useWalletContext(); + const queryClient = useQueryClient(); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -39,6 +44,16 @@ export function useToggleVault({ onSuccess }: UseToggleVaultParams = {}) { await submitAndExtractAddress(signedXdr); + try { + await updateCampaignStatusByVaultId( + vaultContractId, + enabled ? "CLAIMABLE" : "FUNDRAISING", + ); + await queryClient.invalidateQueries({ queryKey: ["campaigns"] }); + } catch { + // Campaign may not exist or vaultId not linked; status update is best-effort + } + onSuccess?.(); } catch (e) { const message = e instanceof Error ? e.message : "Unexpected error"; diff --git a/apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts b/apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts index 02b1142..fbc9e5f 100644 --- a/apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts +++ b/apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts @@ -1,39 +1,14 @@ +import { httpClient } from "@/lib/httpClient"; import type { Campaign } from "@/features/campaigns/types/campaign.types"; -const CORE_API = "/core-api"; - -const API_KEY = process.env.NEXT_PUBLIC_CORE_API_KEY ?? ""; - -async function post(path: string, body: unknown): Promise { - const res = await fetch(`${CORE_API}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json", "x-api-key": API_KEY }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error( - (err as { message?: string }).message ?? - `Error ${res.status} en ${path}`, - ); - } - return res.json() as Promise; -} - export async function getCampaigns(): Promise { - const res = await fetch(`${CORE_API}/campaigns`, { - headers: { "x-api-key": API_KEY }, - }); - if (!res.ok) throw new Error("No se pudieron cargar las campañas."); - return res.json(); + const { data } = await httpClient.get("/campaigns"); + return data; } export async function getCampaignById(id: string): Promise { - const res = await fetch(`${CORE_API}/campaigns/${id}`, { - headers: { "x-api-key": API_KEY }, - }); - if (!res.ok) throw new Error("No se pudo cargar la campaña."); - return res.json(); + const { data } = await httpClient.get(`/campaigns/${id}`); + return data; } export async function deployAll(params: { @@ -46,25 +21,32 @@ export async function deployAll(params: { maxPerInvestor: number; callerPublicKey: string; }): Promise<{ unsignedXdr: string }> { - return post("/deploy/all", params); + const { data } = await httpClient.post<{ unsignedXdr: string }>( + "/deploy/all", + params, + ); + return data; } export async function updateCampaignStatus( id: string, status: string, ): Promise { - const res = await fetch(`${CORE_API}/campaigns/${id}/status`, { - method: "PATCH", - headers: { "Content-Type": "application/json", "x-api-key": API_KEY }, - body: JSON.stringify({ status }), + const { data } = await httpClient.patch(`/campaigns/${id}/status`, { + status, }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error( - (err as { message?: string }).message ?? `Error ${res.status}`, - ); - } - return res.json(); + return data; +} + +export async function updateCampaignStatusByVaultId( + vaultId: string, + status: string, +): Promise { + const { data } = await httpClient.patch( + `/campaigns/by-vault/${vaultId}/status`, + { status }, + ); + return data; } export async function createCampaign(params: { @@ -80,35 +62,11 @@ export async function createCampaign(params: { tokenSaleId: string; vaultId?: string; }): Promise<{ id: string }> { - return post("/campaigns", params); -} - -async function get(path: string): Promise { - const res = await fetch(`${CORE_API}${path}`, { - headers: { "x-api-key": API_KEY }, - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error( - (err as { message?: string }).message ?? `Error ${res.status} on ${path}`, - ); - } - return res.json() as Promise; -} - -async function patch(path: string, body: unknown): Promise { - const res = await fetch(`${CORE_API}${path}`, { - method: "PATCH", - headers: { "Content-Type": "application/json", "x-api-key": API_KEY }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error( - (err as { message?: string }).message ?? `Error ${res.status} on ${path}`, - ); - } - return res.json() as Promise; + const { data } = await httpClient.post<{ id: string }>( + "/campaigns", + params, + ); + return data; } export async function enableVault(params: { @@ -117,21 +75,29 @@ export async function enableVault(params: { enabled: boolean; callerPublicKey: string; }): Promise<{ unsignedXdr: string }> { - return post("/vault/availability-for-exchange", params); + const { data } = await httpClient.post<{ unsignedXdr: string }>( + "/vault/availability-for-exchange", + params, + ); + return data; } export async function getVaultIsEnabled( contractId: string, callerPublicKey: string, ): Promise<{ enabled: boolean }> { - return get( + const { data } = await httpClient.get<{ enabled: boolean }>( `/vault/is-enabled?contractId=${contractId}&callerPublicKey=${callerPublicKey}`, ); + return data; } export async function updateCampaignVaultId( campaignId: string, vaultId: string, ): Promise { - return patch(`/campaigns/${campaignId}`, { vaultId }); + const { data } = await httpClient.patch(`/campaigns/${campaignId}`, { + vaultId, + }); + return data; } diff --git a/apps/backoffice-tokenization/src/features/home/HeroSection.tsx b/apps/backoffice-tokenization/src/features/home/HeroSection.tsx index 05f9e66..8753db0 100644 --- a/apps/backoffice-tokenization/src/features/home/HeroSection.tsx +++ b/apps/backoffice-tokenization/src/features/home/HeroSection.tsx @@ -20,7 +20,7 @@ export const HeroSection = () => { project, from contract deployment to milestone execution.

- + Open App
diff --git a/apps/backoffice-tokenization/src/features/tokens/services/token.service.ts b/apps/backoffice-tokenization/src/features/tokens/services/token.service.ts index ec257d4..7bf2643 100644 --- a/apps/backoffice-tokenization/src/features/tokens/services/token.service.ts +++ b/apps/backoffice-tokenization/src/features/tokens/services/token.service.ts @@ -1,4 +1,4 @@ -import axios, { AxiosInstance } from "axios"; +import { httpClient } from "@/lib/httpClient"; export type DeployTokenResponse = { success: boolean; @@ -13,22 +13,8 @@ export type DeployTokenParams = { }; export class TokenService { - private readonly apiUrl: string; - private readonly axios: AxiosInstance; - - constructor() { - // If NEXT_PUBLIC_API_URL is set, use it. Otherwise, use relative path /api - // This allows the service to work both with external APIs and Next.js route handlers - const envApiUrl = process.env.NEXT_PUBLIC_API_URL; - this.apiUrl = envApiUrl && envApiUrl.trim() !== "" ? envApiUrl : "/api"; - - this.axios = axios.create({ - baseURL: this.apiUrl, - }); - } - async deployToken(params: DeployTokenParams): Promise { - const response = await this.axios.post("/deploy", { + const response = await httpClient.post("/deploy", { escrowContractId: params.escrowContractId, tokenName: params.tokenName, tokenSymbol: params.tokenSymbol, diff --git a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts index 96bdaa3..88c64be 100644 --- a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts +++ b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/useEnableVault.ts @@ -8,6 +8,8 @@ import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/ import { signTransaction } from "@tokenization/tw-blocks-shared/src/wallet-kit/wallet-kit"; import { SendTransactionService } from "@/lib/sendTransactionService"; import { toastSuccessWithTx } from "@/lib/toastWithTx"; +import { updateCampaignStatusByVaultId } from "@/features/campaigns/services/campaigns.api"; +import { useQueryClient } from "@tanstack/react-query"; export type EnableVaultFormValues = { vaultContractAddress: string; @@ -19,6 +21,7 @@ type UseEnableVaultParams = { export function useEnableVault(params?: UseEnableVaultParams) { const { walletAddress } = useWalletContext(); + const queryClient = useQueryClient(); const form = useForm({ defaultValues: { @@ -67,6 +70,16 @@ export function useEnableVault(params?: UseEnableVaultParams) { toastSuccessWithTx("Vault enabled successfully", submitResponse.hash); + try { + await updateCampaignStatusByVaultId( + values.vaultContractAddress, + "CLAIMABLE", + ); + await queryClient.invalidateQueries({ queryKey: ["campaigns"] }); + } catch { + // Campaign may not exist or vaultId not linked; status update is best-effort + } + setResponse(enableResponse); if (enableResponse?.success) { diff --git a/apps/backoffice-tokenization/src/features/vaults/services/vault.service.ts b/apps/backoffice-tokenization/src/features/vaults/services/vault.service.ts index 5d4bf1a..db0162a 100644 --- a/apps/backoffice-tokenization/src/features/vaults/services/vault.service.ts +++ b/apps/backoffice-tokenization/src/features/vaults/services/vault.service.ts @@ -1,4 +1,4 @@ -import axios, { AxiosInstance } from "axios"; +import { httpClient } from "@/lib/httpClient"; export type DeployVaultResponse = { success: boolean; @@ -17,20 +17,6 @@ export type EnableVaultPayload = { }; export class VaultService { - private readonly apiUrl: string; - private readonly axios: AxiosInstance; - - constructor() { - // If NEXT_PUBLIC_API_URL is set, use it. Otherwise, use relative path /api - // This allows the service to work both with external APIs and Next.js route handlers - const envApiUrl = process.env.NEXT_PUBLIC_API_URL; - this.apiUrl = envApiUrl && envApiUrl.trim() !== "" ? envApiUrl : "/api"; - - this.axios = axios.create({ - baseURL: this.apiUrl, - }); - } - async deployVault({ admin, enabled, @@ -44,7 +30,7 @@ export class VaultService { token: string; usdc: string; }): Promise { - const response = await this.axios.post( + const response = await httpClient.post( "/deploy/vault-contract", { admin, @@ -62,7 +48,7 @@ export class VaultService { vaultContractId, adminAddress, }: EnableVaultPayload): Promise { - const response = await this.axios.post( + const response = await httpClient.post( "/vault-contract/availability-for-exchange", { vaultContractId, diff --git a/apps/backoffice-tokenization/src/lib/httpClient.ts b/apps/backoffice-tokenization/src/lib/httpClient.ts index 65474d9..6ff496d 100644 --- a/apps/backoffice-tokenization/src/lib/httpClient.ts +++ b/apps/backoffice-tokenization/src/lib/httpClient.ts @@ -1,6 +1,6 @@ import { createHttpClient } from "@tokenization/shared/lib/httpClient"; export const httpClient = createHttpClient({ - baseURL: "/core-api", + baseURL: process.env.NEXT_PUBLIC_CORE_API_URL ?? "http://localhost:4000", apiKey: process.env.NEXT_PUBLIC_BACKOFFICE_API_KEY ?? "", }); diff --git a/apps/core/src/app.module.ts b/apps/core/src/app.module.ts index d5874b2..a935a73 100644 --- a/apps/core/src/app.module.ts +++ b/apps/core/src/app.module.ts @@ -10,6 +10,7 @@ import { LoansModule } from './loans/loans.module'; import { ParticipationTokenModule } from './participation-token/participation-token.module'; import { VaultModule } from './vault/vault.module'; import { TokenSaleModule } from './token-sale/token-sale.module'; +import { HelperModule } from './helper/helper.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { TokenSaleModule } from './token-sale/token-sale.module'; ParticipationTokenModule, VaultModule, TokenSaleModule, +HelperModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/core/src/campaigns/campaigns.controller.ts b/apps/core/src/campaigns/campaigns.controller.ts index ca845dd..0991bc0 100644 --- a/apps/core/src/campaigns/campaigns.controller.ts +++ b/apps/core/src/campaigns/campaigns.controller.ts @@ -31,6 +31,14 @@ export class CampaignsController { return this.campaignsService.create(dto); } + @Patch('by-vault/:vaultId/status') + updateStatusByVaultId( + @Param('vaultId') vaultId: string, + @Body() dto: UpdateCampaignStatusDto, + ) { + return this.campaignsService.updateStatusByVaultId(vaultId, dto); + } + @Patch(':id/status') updateStatus(@Param('id') id: string, @Body() dto: UpdateCampaignStatusDto) { return this.campaignsService.updateStatus(id, dto); diff --git a/apps/core/src/campaigns/campaigns.service.ts b/apps/core/src/campaigns/campaigns.service.ts index b2a1b0c..2694abb 100644 --- a/apps/core/src/campaigns/campaigns.service.ts +++ b/apps/core/src/campaigns/campaigns.service.ts @@ -14,7 +14,11 @@ const ALLOWED_TRANSITIONS: Record = { [CampaignStatus.FUNDRAISING]: [CampaignStatus.ACTIVE, CampaignStatus.PAUSED], [CampaignStatus.ACTIVE]: [CampaignStatus.REPAYMENT, CampaignStatus.PAUSED], [CampaignStatus.REPAYMENT]: [CampaignStatus.CLAIMABLE, CampaignStatus.PAUSED], - [CampaignStatus.CLAIMABLE]: [CampaignStatus.CLOSED, CampaignStatus.PAUSED], + [CampaignStatus.CLAIMABLE]: [ + CampaignStatus.CLOSED, + CampaignStatus.PAUSED, + CampaignStatus.FUNDRAISING, + ], [CampaignStatus.CLOSED]: [], [CampaignStatus.PAUSED]: [], }; @@ -66,6 +70,10 @@ export class CampaignsService { const currentStatus = campaign.status; const newStatus = dto.status; + if (currentStatus === newStatus) { + return campaign; + } + this.validateStatusTransition( currentStatus, newStatus, @@ -92,6 +100,35 @@ export class CampaignsService { }); } + async findOneByVaultId(vaultId: string) { + const campaign = await this.prisma.campaign.findFirst({ + where: { vaultId }, + include: { investments: true }, + }); + if (!campaign) { + throw new NotFoundException( + `Campaign with vaultId ${vaultId} not found`, + ); + } + return campaign; + } + + async updateStatusByVaultId( + vaultId: string, + dto: UpdateCampaignStatusDto, + ) { + const campaign = await this.findOneByVaultId(vaultId); + + if (campaign.status === dto.status) { + return campaign; + } + + return this.prisma.campaign.update({ + where: { id: campaign.id }, + data: { status: dto.status }, + }); + } + private validateStatusTransition( current: CampaignStatus, next: CampaignStatus, diff --git a/apps/core/src/helper/dto/send-transaction.dto.ts b/apps/core/src/helper/dto/send-transaction.dto.ts new file mode 100644 index 0000000..8252d74 --- /dev/null +++ b/apps/core/src/helper/dto/send-transaction.dto.ts @@ -0,0 +1,7 @@ +import { IsString, IsNotEmpty } from 'class-validator'; + +export class SendTransactionDto { + @IsString() + @IsNotEmpty() + signedXdr: string; +} diff --git a/apps/core/src/helper/helper.controller.ts b/apps/core/src/helper/helper.controller.ts new file mode 100644 index 0000000..512f8f5 --- /dev/null +++ b/apps/core/src/helper/helper.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Post, Body } from '@nestjs/common'; +import { HelperService } from './helper.service'; +import { SendTransactionDto } from './dto/send-transaction.dto'; +import { rpc } from '@stellar/stellar-sdk'; + +@Controller('helper') +export class HelperController { + constructor(private readonly helperService: HelperService) {} + + @Post('send-transaction') + async sendTransaction(@Body() dto: SendTransactionDto) { + try { + return await this.helperService.submitTransaction(dto.signedXdr); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + return { + status: rpc.Api.GetTransactionStatus.FAILED, + message: + message || + 'An unknown error occurred while submitting the transaction.', + }; + } + } +} diff --git a/apps/core/src/helper/helper.module.ts b/apps/core/src/helper/helper.module.ts new file mode 100644 index 0000000..596d70d --- /dev/null +++ b/apps/core/src/helper/helper.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { HelperController } from './helper.controller'; +import { HelperService } from './helper.service'; + +@Module({ + controllers: [HelperController], + providers: [HelperService], +}) +export class HelperModule {} diff --git a/apps/core/src/helper/helper.service.ts b/apps/core/src/helper/helper.service.ts new file mode 100644 index 0000000..3f205c6 --- /dev/null +++ b/apps/core/src/helper/helper.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { + TransactionBuilder, + Networks, + Horizon, + rpc, +} from '@stellar/stellar-sdk'; + +const HORIZON_TESTNET = 'https://horizon-testnet.stellar.org'; + +@Injectable() +export class HelperService { + private readonly horizonUrl: string; + + constructor() { + this.horizonUrl = process.env.HORIZON_URL ?? HORIZON_TESTNET; + } + + async submitTransaction(signedXdr: string): Promise<{ + status: string; + message: string; + hash?: string; + }> { + const server = new Horizon.Server(this.horizonUrl, { allowHttp: true }); + + const transaction = TransactionBuilder.fromXDR( + signedXdr, + Networks.TESTNET, + ); + + const response = await server.submitTransaction(transaction); + + if (!response.successful) { + return { + status: rpc.Api.GetTransactionStatus.FAILED, + message: + 'The transaction could not be sent to the Stellar network for some unknown reason. Please try again.', + }; + } + + return { + status: rpc.Api.GetTransactionStatus.SUCCESS, + message: + 'The transaction has been successfully sent to the Stellar network.', + hash: response.hash, + }; + } +} diff --git a/apps/core/src/token-sale/token-sale.service.ts b/apps/core/src/token-sale/token-sale.service.ts index 6090c63..a3e0068 100644 --- a/apps/core/src/token-sale/token-sale.service.ts +++ b/apps/core/src/token-sale/token-sale.service.ts @@ -8,7 +8,7 @@ import { SetAdminDto } from './dto/set-admin.dto'; @Injectable() export class TokenSaleService { - constructor(private readonly soroban: SorobanService) {} + constructor(private readonly soroban: SorobanService) { } // ── Writes ── @@ -23,7 +23,6 @@ export class TokenSaleService { amount: toMicroUSDC(dto.amount), }, dto.callerPublicKey, - 'token-sale', ); } @@ -36,7 +35,6 @@ export class TokenSaleService { new_max_per_investor: toMicroUSDC(dto.newMaxPerInvestor), }, dto.callerPublicKey, - 'token-sale', ); } @@ -46,7 +44,6 @@ export class TokenSaleService { 'set_token', { new_token: dto.newToken }, dto.callerPublicKey, - 'token-sale', ); } @@ -56,13 +53,12 @@ export class TokenSaleService { 'set_admin', { new_admin: dto.newAdmin }, dto.callerPublicKey, - 'token-sale', ); } // ── Reads ── getAdmin(contractId: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'get_admin', {}, callerPublicKey, 'token-sale'); + return this.soroban.readContractState(contractId, 'get_admin', {}, callerPublicKey); } } diff --git a/apps/core/src/vault/vault.service.ts b/apps/core/src/vault/vault.service.ts index be32a73..7ba1625 100644 --- a/apps/core/src/vault/vault.service.ts +++ b/apps/core/src/vault/vault.service.ts @@ -10,7 +10,7 @@ export class VaultService { constructor( private readonly soroban: SorobanService, private readonly prisma: PrismaService, - ) {} + ) { } async availabilityForExchange(dto: AvailabilityForExchangeDto): Promise { const unsignedXdr = await this.soroban.buildContractCallTransaction( @@ -18,7 +18,6 @@ export class VaultService { 'availability_for_exchange', { enabled: dto.enabled }, dto.callerPublicKey, - 'vault', ); if (dto.enabled && dto.campaignId) { @@ -37,43 +36,42 @@ export class VaultService { 'claim', { beneficiary: dto.beneficiary }, dto.callerPublicKey, - 'vault', ); } getOverview(contractId: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'get_vault_overview', {}, callerPublicKey, 'vault'); + return this.soroban.readContractState(contractId, 'get_vault_overview', {}, callerPublicKey); } previewClaim(contractId: string, beneficiary: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'preview_claim', { beneficiary }, callerPublicKey, 'vault'); + return this.soroban.readContractState(contractId, 'preview_claim', { beneficiary }, callerPublicKey); } isEnabled(contractId: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'is_enabled', {}, callerPublicKey, 'vault'); + return this.soroban.readContractState(contractId, 'is_enabled', {}, callerPublicKey); } getUsdcBalance(contractId: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'get_vault_usdc_balance', {}, callerPublicKey, 'vault'); + return this.soroban.readContractState(contractId, 'get_vault_usdc_balance', {}, callerPublicKey); } getTotalTokensRedeemed(contractId: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'get_total_tokens_redeemed', {}, callerPublicKey, 'vault'); + return this.soroban.readContractState(contractId, 'get_total_tokens_redeemed', {}, callerPublicKey); } getAdmin(contractId: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'get_admin', {}, callerPublicKey, 'vault'); + return this.soroban.readContractState(contractId, 'get_admin', {}, callerPublicKey); } getRoiPercentage(contractId: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'get_roi_percentage', {}, callerPublicKey, 'vault'); + return this.soroban.readContractState(contractId, 'get_roi_percentage', {}, callerPublicKey); } getTokenAddress(contractId: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'get_token_address', {}, callerPublicKey, 'vault'); + return this.soroban.readContractState(contractId, 'get_token_address', {}, callerPublicKey); } getUsdcAddress(contractId: string, callerPublicKey: string): Promise { - return this.soroban.readContractState(contractId, 'get_usdc_address', {}, callerPublicKey, 'vault'); + return this.soroban.readContractState(contractId, 'get_usdc_address', {}, callerPublicKey); } } diff --git a/apps/investor-tokenization/services/wasm/soroban_token_contract.wasm b/apps/investor-tokenization/services/wasm/soroban_token_contract.wasm deleted file mode 100644 index 733c647..0000000 Binary files a/apps/investor-tokenization/services/wasm/soroban_token_contract.wasm and /dev/null differ diff --git a/apps/investor-tokenization/services/wasm/token_sale.wasm b/apps/investor-tokenization/services/wasm/token_sale.wasm deleted file mode 100644 index c543cd8..0000000 Binary files a/apps/investor-tokenization/services/wasm/token_sale.wasm and /dev/null differ diff --git a/apps/investor-tokenization/services/wasm/vault_contract.wasm b/apps/investor-tokenization/services/wasm/vault_contract.wasm deleted file mode 100644 index 0497db3..0000000 Binary files a/apps/investor-tokenization/services/wasm/vault_contract.wasm and /dev/null differ diff --git a/apps/investor-tokenization/src/app/api/deploy/route.ts b/apps/investor-tokenization/src/app/api/deploy/route.ts deleted file mode 100644 index 6e45bbf..0000000 --- a/apps/investor-tokenization/src/app/api/deploy/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextResponse } from "next/server"; -import { SorobanClient } from "../../../lib/sorobanClient"; -import { deployTokenContracts } from "../../../lib/tokenDeploymentService"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; -const SOURCE_SECRET = process.env.SOURCE_SECRET || ""; - -export async function POST(request: Request) { - const data = await request.json(); - const { escrowContractId, tokenName, tokenSymbol } = data ?? {}; - - if (!escrowContractId || !tokenName || !tokenSymbol) { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: "escrowContractId, tokenName, and tokenSymbol are required", - }), - { status: 400 }, - ); - } - - try { - const sorobanClient = new SorobanClient({ - rpcUrl: RPC_URL, - sourceSecret: SOURCE_SECRET, - }); - - const { tokenFactoryAddress, tokenSaleAddress } = - await deployTokenContracts(sorobanClient, { - escrowContractId, - tokenName, - tokenSymbol, - }); - - return NextResponse.json({ - success: true, - tokenFactoryAddress, - tokenSaleAddress, - }); - } catch (error) { - console.error("Deployment error:", error); - return new Response( - JSON.stringify({ - error: "Internal Server Error", - details: error instanceof Error ? error.message : String(error), - }), - { status: 500 }, - ); - } -} diff --git a/apps/investor-tokenization/src/app/api/deploy/vault-contract/route.ts b/apps/investor-tokenization/src/app/api/deploy/vault-contract/route.ts deleted file mode 100644 index 0273b4b..0000000 --- a/apps/investor-tokenization/src/app/api/deploy/vault-contract/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextResponse } from "next/server"; -import { SorobanClient } from "../../../../lib/sorobanClient"; -import { deployVaultContract } from "../../../../lib/vaultDeploymentService"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; -const SOURCE_SECRET = process.env.SOURCE_SECRET || ""; - -export async function POST(request: Request) { - const data = await request.json(); - const { admin, enabled, price, token, usdc } = data ?? {}; - - if (!admin || typeof enabled !== "boolean" || !price || !token || !usdc) { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: - "admin, enabled (boolean), price, token, and usdc are required", - }), - { status: 400 }, - ); - } - - if (typeof price !== "number" && typeof price !== "string") { - return new Response( - JSON.stringify({ - error: "Invalid price", - details: "price must be a number or string", - }), - { status: 400 }, - ); - } - - try { - const sorobanClient = new SorobanClient({ - rpcUrl: RPC_URL, - sourceSecret: SOURCE_SECRET, - }); - - const { vaultContractAddress } = await deployVaultContract(sorobanClient, { - admin, - enabled, - price, - token, - usdc, - }); - - return NextResponse.json({ - success: true, - vaultContractAddress, - }); - } catch (error) { - console.error("Vault deployment error:", error); - return new Response( - JSON.stringify({ - error: "Internal Server Error", - details: error instanceof Error ? error.message : String(error), - }), - { status: 500 }, - ); - } -} diff --git a/apps/investor-tokenization/src/app/api/helper/send-transaction/route.ts b/apps/investor-tokenization/src/app/api/helper/send-transaction/route.ts deleted file mode 100644 index 24824b7..0000000 --- a/apps/investor-tokenization/src/app/api/helper/send-transaction/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import { NextResponse } from "next/server"; - -export async function POST(request: Request) { - const { signedXdr } = await request.json(); - const server = new StellarSDK.Horizon.Server( - "https://horizon-testnet.stellar.org", - { - allowHttp: true, - }, - ); - - try { - const transaction = StellarSDK.TransactionBuilder.fromXDR( - signedXdr, - StellarSDK.Networks.TESTNET, - ); - - const response = await server.submitTransaction(transaction); - if (!response.successful) { - return NextResponse.json({ - status: StellarSDK.rpc.Api.GetTransactionStatus.FAILED, - message: - "The transaction could not be sent to the Stellar network for some unknown reason. Please try again.", - }); - } - return NextResponse.json({ - status: StellarSDK.rpc.Api.GetTransactionStatus.SUCCESS, - message: - "The transaction has been successfully sent to the Stellar network.", - hash: response.hash, - }); - } catch (error) { - console.error("Transaction submission error:", error); - return NextResponse.json({ - status: StellarSDK.rpc.Api.GetTransactionStatus.FAILED, - message: - error instanceof Error - ? error.message - : "An unknown error occurred while submitting the transaction.", - }); - } -} diff --git a/apps/investor-tokenization/src/app/api/token-balance/route.ts b/apps/investor-tokenization/src/app/api/token-balance/route.ts deleted file mode 100644 index 9747091..0000000 --- a/apps/investor-tokenization/src/app/api/token-balance/route.ts +++ /dev/null @@ -1,164 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import { NextResponse } from "next/server"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; - -export async function POST(request: Request) { - const data = await request.json(); - const { tokenFactoryAddress, address } = data ?? {}; - - if (!tokenFactoryAddress || !address) { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: "tokenFactoryAddress and address are required", - }), - { status: 400 }, - ); - } - - try { - const server = new StellarSDK.rpc.Server(RPC_URL); - - // Try to get balance using contract function call first (works for Stellar Asset Contracts) - // This is the preferred method as it works for both custom contracts and SAC - try { - const sourceAccount = await server.getAccount(address); - const contract = new StellarSDK.Contract(tokenFactoryAddress); - - const transaction = new StellarSDK.TransactionBuilder(sourceAccount, { - fee: StellarSDK.BASE_FEE, - networkPassphrase: StellarSDK.Networks.TESTNET, - }) - .addOperation( - contract.call( - "balance", - StellarSDK.nativeToScVal(new StellarSDK.Address(address), { - type: "address", - }), - ), - ) - .setTimeout(30) - .build(); - - const simulation = await server.simulateTransaction(transaction); - - // Check if simulation was successful and has results - if ( - "results" in simulation && - Array.isArray(simulation.results) && - simulation.results.length > 0 - ) { - const result = simulation.results[0]; - if (result && "retval" in result && result.retval) { - const balanceVal = StellarSDK.scValToNative(result.retval); - const balance = - typeof balanceVal === "bigint" - ? Number(balanceVal) - : Number(balanceVal); - - return NextResponse.json({ - success: true, - balance: balance.toString(), - }); - } - } - } catch (functionCallError) { - // If function call fails, fall back to reading from storage - console.log( - "Balance function call failed, trying storage read:", - functionCallError, - ); - } - - // Fallback: Read balance directly from contract storage - // The balance is stored in persistent storage with key DataKey::Balance(address) - const contractAddress = StellarSDK.Address.fromString(tokenFactoryAddress); - const userAddress = StellarSDK.Address.fromString(address); - - // Create the storage key: DataKey::Balance(userAddress) - // In Soroban, enum variants are encoded as vectors: [variant_index, ...data] - // Balance is variant index 1 (0=Allowance, 1=Balance, 2=State, 3=Admin) - // We need to create a vector ScVal: [1, userAddress] - const vecElements: StellarSDK.xdr.ScVal[] = [ - StellarSDK.xdr.ScVal.scvU32(1), // Balance variant index - userAddress.toScVal(), // The address parameter - ]; - const balanceKey = StellarSDK.xdr.ScVal.scvVec(vecElements); - - const ledgerKey = StellarSDK.xdr.LedgerKey.contractData( - new StellarSDK.xdr.LedgerKeyContractData({ - contract: contractAddress.toScAddress(), - key: balanceKey, - durability: StellarSDK.xdr.ContractDataDurability.persistent(), - }), - ); - - // Get the ledger entry - const ledgerEntries = await server.getLedgerEntries(ledgerKey); - - // !IMPORTANT: Not working - if ( - !ledgerEntries || - !ledgerEntries.entries || - ledgerEntries.entries.length === 0 - ) { - // No balance entry found means balance is 0 - return NextResponse.json({ - success: true, - balance: "0", - }); - } - - // Parse the storage entry to get the balance - const entry = ledgerEntries.entries[0]; - if (!entry.val || !entry.val.contractData()) { - return NextResponse.json( - { - success: false, - balance: "0", - error: "Invalid contract data format", - }, - { status: 200 }, - ); - } - - const contractData = entry.val.contractData(); - const storageValue = contractData.val(); - - // The value should be an i128 (the balance) - // Parse it from ScVal - let balance: number; - try { - const balanceVal = StellarSDK.scValToNative(storageValue); - balance = - typeof balanceVal === "bigint" - ? Number(balanceVal) - : Number(balanceVal); - } catch (parseError) { - return NextResponse.json( - { - success: false, - balance: "0", - error: `Failed to parse balance: ${parseError instanceof Error ? parseError.message : String(parseError)}`, - }, - { status: 200 }, - ); - } - - return NextResponse.json({ - success: true, - balance: balance.toString(), - }); - } catch (error) { - console.error("Token balance fetch error:", error); - return NextResponse.json( - { - success: false, - balance: "0", - error: error instanceof Error ? error.message : String(error), - }, - { status: 200 }, - ); - } -} diff --git a/apps/investor-tokenization/src/app/api/token-metadata/route.ts b/apps/investor-tokenization/src/app/api/token-metadata/route.ts deleted file mode 100644 index 03e9f54..0000000 --- a/apps/investor-tokenization/src/app/api/token-metadata/route.ts +++ /dev/null @@ -1,114 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import { NextResponse } from "next/server"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; - -export async function POST(request: Request) { - const data = await request.json(); - const { tokenFactoryAddress } = data ?? {}; - - if (!tokenFactoryAddress) { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: "tokenFactoryAddress is required", - }), - { status: 400 }, - ); - } - - try { - const server = new StellarSDK.rpc.Server(RPC_URL); - - // Get a dummy account for simulation - const dummyAccount = await server.getAccount("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF").catch(() => null); - - const account = dummyAccount || { - accountId: () => "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - sequenceNumber: () => "0", - } as StellarSDK.Account; - - const contract = new StellarSDK.Contract(tokenFactoryAddress); - - // Fetch name, symbol, and decimals in parallel - const [nameTx, symbolTx, decimalsTx] = await Promise.all([ - new StellarSDK.TransactionBuilder(account, { - fee: StellarSDK.BASE_FEE, - networkPassphrase: StellarSDK.Networks.TESTNET, - }) - .addOperation(contract.call("name")) - .setTimeout(300) - .build(), - new StellarSDK.TransactionBuilder(account, { - fee: StellarSDK.BASE_FEE, - networkPassphrase: StellarSDK.Networks.TESTNET, - }) - .addOperation(contract.call("symbol")) - .setTimeout(300) - .build(), - new StellarSDK.TransactionBuilder(account, { - fee: StellarSDK.BASE_FEE, - networkPassphrase: StellarSDK.Networks.TESTNET, - }) - .addOperation(contract.call("decimals")) - .setTimeout(300) - .build(), - ]); - - const [nameSim, symbolSim, decimalsSim] = await Promise.all([ - server.simulateTransaction(nameTx), - server.simulateTransaction(symbolTx), - server.simulateTransaction(decimalsTx), - ]); - - // Parse name - let name = "Unknown Token"; - if (!("error" in nameSim) && nameSim.result?.retval) { - try { - const nameVal = StellarSDK.scValToNative(nameSim.result.retval); - name = typeof nameVal === "string" ? nameVal : String(nameVal); - } catch (e) { - console.warn("Failed to parse name:", e); - } - } - - // Parse symbol - let symbol = "TOKEN"; - if (!("error" in symbolSim) && symbolSim.result?.retval) { - try { - const symbolVal = StellarSDK.scValToNative(symbolSim.result.retval); - symbol = typeof symbolVal === "string" ? symbolVal : String(symbolVal); - } catch (e) { - console.warn("Failed to parse symbol:", e); - } - } - - // Parse decimals - let decimals = 7; // Default for Stellar - if (!("error" in decimalsSim) && decimalsSim.result?.retval) { - try { - const decimalsVal = StellarSDK.scValToNative(decimalsSim.result.retval); - decimals = typeof decimalsVal === "number" ? decimalsVal : Number(decimalsVal); - } catch (e) { - console.warn("Failed to parse decimals:", e); - } - } - - return NextResponse.json({ - success: true, - name, - symbol, - decimals, - }); - } catch (error) { - console.error("Token metadata fetch error:", error); - return NextResponse.json({ - success: false, - name: "Unknown Token", - symbol: "TOKEN", - decimals: 7, - error: error instanceof Error ? error.message : String(error), - }, { status: 200 }); - } -} - diff --git a/apps/investor-tokenization/src/app/api/token-sale/buy/route.ts b/apps/investor-tokenization/src/app/api/token-sale/buy/route.ts deleted file mode 100644 index 3826e42..0000000 --- a/apps/investor-tokenization/src/app/api/token-sale/buy/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { adjustPricesToMicroUSDC } from "@/utils/adjustedAmounts"; -import * as StellarSDK from "@stellar/stellar-sdk"; -import { NextResponse } from "next/server"; -import { extractContractError } from "@/lib/contractErrorHandler"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; - -export async function POST(request: Request) { - const data = await request.json(); - const { - tokenSaleContractId, - usdcAddress, - payerAddress, - beneficiaryAddress, - amount, - } = data ?? {}; - - if ( - !tokenSaleContractId || - !usdcAddress || - !payerAddress || - !beneficiaryAddress - ) { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: - "tokenSaleContractId, usdcAddress, payerAddress, and beneficiaryAddress are required", - }), - { status: 400 }, - ); - } - - if (typeof amount !== "number" && typeof amount !== "string") { - return new Response( - JSON.stringify({ - error: "Invalid amount", - details: "amount must be a number or string", - }), - { status: 400 }, - ); - } - - try { - const server = new StellarSDK.rpc.Server(RPC_URL); - const sourceAccount = await server.getAccount(payerAddress); - - const contract = new StellarSDK.Contract(tokenSaleContractId); - const adjustedAmount = adjustPricesToMicroUSDC(Number(amount)); - const transaction = new StellarSDK.TransactionBuilder(sourceAccount, { - fee: StellarSDK.BASE_FEE, - networkPassphrase: StellarSDK.Networks.TESTNET, - }) - .addOperation( - contract.call( - "buy", - StellarSDK.nativeToScVal(new StellarSDK.Address(usdcAddress), { - type: "address", - }), - StellarSDK.nativeToScVal(new StellarSDK.Address(payerAddress), { - type: "address", - }), - StellarSDK.nativeToScVal(new StellarSDK.Address(beneficiaryAddress), { - type: "address", - }), - StellarSDK.nativeToScVal(adjustedAmount, { type: "i128" }), - ), - ) - .setTimeout(300) - .build(); - - const preparedTransaction = await server.prepareTransaction(transaction); - const xdr = preparedTransaction.toXDR(); - - return NextResponse.json({ - success: true, - xdr, - message: - "Transaction built successfully. Sign with wallet and submit to network.", - }); - } catch (error) { - console.error("Buy transaction build error:", error); - const { message, details } = extractContractError(error, "token-sale"); - return new Response( - JSON.stringify({ - error: message, - details: details, - }), - { status: 500 }, - ); - } -} diff --git a/apps/investor-tokenization/src/app/api/trustline/add/route.ts b/apps/investor-tokenization/src/app/api/trustline/add/route.ts deleted file mode 100644 index 216dce4..0000000 --- a/apps/investor-tokenization/src/app/api/trustline/add/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import { NextResponse } from "next/server"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; -const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; - -export async function POST(request: Request) { - const data = await request.json(); - const { address } = data ?? {}; - - if (!address) { - return NextResponse.json( - { error: "Missing required field: address" }, - { status: 400 }, - ); - } - - try { - const server = new StellarSDK.rpc.Server(RPC_URL); - const sourceAccount = await server.getAccount(address); - - const asset = new StellarSDK.Asset("USDC", USDC_ISSUER); - - const transaction = new StellarSDK.TransactionBuilder(sourceAccount, { - fee: StellarSDK.BASE_FEE, - networkPassphrase: StellarSDK.Networks.TESTNET, - }) - .addOperation(StellarSDK.Operation.changeTrust({ asset })) - .setTimeout(300) - .build(); - - return NextResponse.json({ - success: true, - xdr: transaction.toXDR(), - message: "Trustline transaction built. Sign with wallet and submit.", - }); - } catch (error) { - console.error("Trustline transaction build error:", error); - return NextResponse.json( - { - error: "Failed to build trustline transaction", - details: error instanceof Error ? error.message : String(error), - }, - { status: 500 }, - ); - } -} diff --git a/apps/investor-tokenization/src/app/api/vault-contract/availability-for-exchange/route.ts b/apps/investor-tokenization/src/app/api/vault-contract/availability-for-exchange/route.ts deleted file mode 100644 index 95f41f7..0000000 --- a/apps/investor-tokenization/src/app/api/vault-contract/availability-for-exchange/route.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import { NextResponse } from "next/server"; -import { extractContractError } from "@/lib/contractErrorHandler"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; - -export async function POST(request: Request) { - const data = await request.json(); - const { vaultContractId, adminAddress, enabled } = data ?? {}; - - if (!vaultContractId || !adminAddress || typeof enabled !== "boolean") { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: - "vaultContractId, adminAddress, and enabled (boolean) are required", - }), - { status: 400 }, - ); - } - - try { - const server = new StellarSDK.rpc.Server(RPC_URL); - const sourceAccount = await server.getAccount(adminAddress); - - const contract = new StellarSDK.Contract(vaultContractId); - - const transaction = new StellarSDK.TransactionBuilder(sourceAccount, { - fee: StellarSDK.BASE_FEE, - networkPassphrase: StellarSDK.Networks.TESTNET, - }) - .addOperation( - contract.call( - "availability_for_exchange", - StellarSDK.nativeToScVal(new StellarSDK.Address(adminAddress), { - type: "address", - }), - StellarSDK.nativeToScVal(enabled, { type: "bool" }), - ), - ) - .setTimeout(300) - .build(); - - const preparedTransaction = await server.prepareTransaction(transaction); - const xdr = preparedTransaction.toXDR(); - - return NextResponse.json({ - success: true, - xdr, - message: - "Transaction built successfully. Sign with wallet and submit to network.", - }); - } catch (error) { - console.error("Availability for exchange transaction build error:", error); - const { message, details } = extractContractError(error, "vault"); - return new Response( - JSON.stringify({ - error: message, - details: details, - }), - { status: 500 }, - ); - } -} \ No newline at end of file diff --git a/apps/investor-tokenization/src/app/api/vault-contract/claim/route.ts b/apps/investor-tokenization/src/app/api/vault-contract/claim/route.ts deleted file mode 100644 index 81ff783..0000000 --- a/apps/investor-tokenization/src/app/api/vault-contract/claim/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as StellarSDK from "@stellar/stellar-sdk"; -import { NextResponse } from "next/server"; -import { extractContractError } from "@/lib/contractErrorHandler"; - -const RPC_URL = "https://soroban-testnet.stellar.org"; - -export async function POST(request: Request) { - const data = await request.json(); - const { vaultContractId, beneficiaryAddress } = data ?? {}; - - if (!vaultContractId || !beneficiaryAddress) { - return new Response( - JSON.stringify({ - error: "Missing required fields", - details: "vaultContractId and beneficiaryAddress are required", - }), - { status: 400 }, - ); - } - - try { - const server = new StellarSDK.rpc.Server(RPC_URL); - const sourceAccount = await server.getAccount(beneficiaryAddress); - - const contract = new StellarSDK.Contract(vaultContractId); - - const transaction = new StellarSDK.TransactionBuilder(sourceAccount, { - fee: StellarSDK.BASE_FEE, - networkPassphrase: StellarSDK.Networks.TESTNET, - }) - .addOperation( - contract.call( - "claim", - StellarSDK.nativeToScVal(new StellarSDK.Address(beneficiaryAddress), { - type: "address", - }), - ), - ) - .setTimeout(300) - .build(); - - const preparedTransaction = await server.prepareTransaction(transaction); - const xdr = preparedTransaction.toXDR(); - - return NextResponse.json({ - success: true, - xdr, - message: - "Transaction built successfully. Sign with wallet and submit to network.", - }); - } catch (error) { - console.error("Claim transaction build error:", error); - const { message, details } = extractContractError(error, "vault"); - return new Response( - JSON.stringify({ - error: message, - details: details, - }), - { status: 500 }, - ); - } -} \ No newline at end of file diff --git a/apps/investor-tokenization/src/app/my-investments/page.tsx b/apps/investor-tokenization/src/app/my-investments/page.tsx index ae82820..2a1e06c 100644 --- a/apps/investor-tokenization/src/app/my-investments/page.tsx +++ b/apps/investor-tokenization/src/app/my-investments/page.tsx @@ -24,6 +24,8 @@ function toCampaign(inv: InvestmentFromApi): Campaign { investedAmount: Number(inv.usdcAmount), currency: "USDC", vaultId: inv.campaign.vaultId ?? null, + escrowId: inv.campaign.escrowId, + poolSize: Number(inv.campaign.poolSize), }; } diff --git a/apps/investor-tokenization/src/features/investments/services/investment.service.ts b/apps/investor-tokenization/src/features/investments/services/investment.service.ts index 99ca8db..462d112 100644 --- a/apps/investor-tokenization/src/features/investments/services/investment.service.ts +++ b/apps/investor-tokenization/src/features/investments/services/investment.service.ts @@ -46,6 +46,7 @@ export type InvestmentFromApi = { description: string | null; status: string; escrowId: string; + poolSize: number; tokenFactoryId: string | null; tokenSaleId: string | null; vaultId: string | null; diff --git a/apps/investor-tokenization/src/features/roi/components/campaign-card.tsx b/apps/investor-tokenization/src/features/roi/components/campaign-card.tsx index eda3b34..5cea159 100644 --- a/apps/investor-tokenization/src/features/roi/components/campaign-card.tsx +++ b/apps/investor-tokenization/src/features/roi/components/campaign-card.tsx @@ -1,16 +1,23 @@ "use client"; import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; import { Badge } from "@tokenization/ui/badge"; import { Button } from "@tokenization/ui/button"; import { CampaignCard as SharedCampaignCard } from "@tokenization/ui/campaign-card"; import { cn } from "@tokenization/shared/lib/utils"; -import { ExternalLink, FileText } from "lucide-react"; +import { + Banknote, + CheckCircle, + Circle, + ExternalLink, + FileText, +} from "lucide-react"; +import { useGetEscrowFromIndexerByContractIds } from "@trustless-work/escrow"; +import type { MultiReleaseMilestone } from "@trustless-work/escrow/types"; import type { Campaign } from "../types/campaign.types"; import { CAMPAIGN_STATUS_CONFIG } from "../constants/campaign-status"; - -const ESCROW_EXPLORER_URL = - "https://stellar.expert/explorer/testnet/contract/CBBTYM6SM5KATWKLNXRUOGRVVGA762EZTB6LE7XEKZAX6VHVF7SYGIFO"; +import { fromStroops } from "@/utils/adjustedAmounts"; interface CampaignCardProps { campaign: Campaign; @@ -18,21 +25,80 @@ interface CampaignCardProps { } export function CampaignCard({ campaign, onClaimRoi }: CampaignCardProps) { - const { title, description, status, id, loansCompleted, investedAmount, currency } = campaign; + const { title, description, status, id, escrowId, poolSize } = campaign; const statusCfg = CAMPAIGN_STATUS_CONFIG[status]; - const formattedInvested = investedAmount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const escrowExplorerUrl = `https://stellar.expert/explorer/testnet/contract/${escrowId}`; + + const { getEscrowByContractIds } = useGetEscrowFromIndexerByContractIds(); + + type EscrowFromIndexer = { + milestones?: MultiReleaseMilestone[]; + [key: string]: unknown; + }; + + const { data: escrowData } = useQuery({ + queryKey: ["escrow", escrowId], + queryFn: async (): Promise => { + const data = (await getEscrowByContractIds({ + contractIds: [escrowId], + validateOnChain: true, + })) as unknown; + + if (!Array.isArray(data) || data.length === 0) { + return null; + } + + return data[0] as EscrowFromIndexer; + }, + enabled: !!escrowId, + staleTime: 1000 * 60 * 5, + }); + + const allMilestones = (escrowData?.milestones ?? []) as MultiReleaseMilestone[]; + const visibleMilestones = allMilestones.slice(1); + const assigned = allMilestones.reduce( + (sum, m) => sum + fromStroops((m.amount as number) ?? 0), + 0, + ); + const loansCompleted = visibleMilestones.filter( + (m) => m.status === "Approved", + ).length; + const totalLoans = visibleMilestones.length; + const progressValue = + totalLoans > 0 ? Math.min(100, (loansCompleted / totalLoans) * 100) : 0; return ( - {statusCfg.label} - + <> +
+ + {statusCfg.label} + + + +
+ } actions={ status === "CLAIMABLE" ? ( @@ -47,18 +113,48 @@ export function CampaignCard({ campaign, onClaimRoi }: CampaignCardProps) { ) : null } footer={ - +
+ + Pool Size: USDC{" "} + {assigned.toLocaleString("en-US", { + minimumFractionDigits: 2, + })}{" "} + / USDC{" "} + {poolSize.toLocaleString("en-US", { minimumFractionDigits: 2 })} + +
} - progress={{ label: "Loans Completed", value: loansCompleted }} - /> + progress={{ label: "Loans Completed", value: progressValue }} + > + {visibleMilestones.length > 0 ? ( + <> +

+ Loans +

+
    + {visibleMilestones.map((m, i) => ( +
  • + {m.flags?.approved ? ( + + ) : m.flags?.released ? ( + + ) : ( + + )} + + {m.description || `Loan ${i + 1}`} + + {m.amount} USDC +
  • + ))} +
+ + ) : ( +

No loans available.

+ )} +
); } diff --git a/apps/investor-tokenization/src/features/roi/data/mock-campaigns.ts b/apps/investor-tokenization/src/features/roi/data/mock-campaigns.ts deleted file mode 100644 index 302c581..0000000 --- a/apps/investor-tokenization/src/features/roi/data/mock-campaigns.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Campaign } from "../types/campaign.types"; - -export const mockCampaigns: Campaign[] = [ - { - id: "1", - title: "Coffee Producers Cooperative", - description: - "Expanding sustainable harvest infrastructure in Antioquia. This project aims to implement water-saving processing systems for 50 local families.", - status: "ACTIVE", - loansCompleted: 10, - investedAmount: 25000, - currency: "USD", - vaultId: null, - }, - { - id: "2", - title: "Artisan Ceramic Collective", - description: - "Supporting traditional pottery techniques and new kiln installations. The collective brings together 30 artisans from the region to scale production and reach new markets.", - status: "ACTIVE", - loansCompleted: 8, - investedAmount: 10000, - currency: "USD", - vaultId: null, - }, - { - id: "3", - title: "Urban Agriculture Network", - description: - "Rooftop and community garden expansion in Medellín. This initiative creates green jobs and improves food security through urban farming training and shared infrastructure.", - status: "CLAIMABLE", - loansCompleted: 12, - investedAmount: 50000, - currency: "USD", - vaultId: null, - }, -]; diff --git a/apps/investor-tokenization/src/features/roi/types/campaign.types.ts b/apps/investor-tokenization/src/features/roi/types/campaign.types.ts index 7352b78..e62582c 100644 --- a/apps/investor-tokenization/src/features/roi/types/campaign.types.ts +++ b/apps/investor-tokenization/src/features/roi/types/campaign.types.ts @@ -35,4 +35,6 @@ export type Campaign = { investedAmount: number; currency: string; vaultId: string | null; + escrowId: string; + poolSize: number; }; diff --git a/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx b/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx index 8989348..0de816d 100644 --- a/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx +++ b/apps/investor-tokenization/src/features/tokens/components/InvestDialog.tsx @@ -32,7 +32,8 @@ import { MultiReleaseMilestone } from "@trustless-work/escrow"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { fromStroops } from "@/utils/adjustedAmounts"; -import axios from "axios"; +import { httpClient } from "@/lib/httpClient"; +import { Networks, rpc, TransactionBuilder } from "@stellar/stellar-sdk"; type InvestFormValues = { amount: number; @@ -67,6 +68,11 @@ export function InvestDialog({ const onSubmit = async (values: InvestFormValues) => { setErrorMessage(null); + + const server = new rpc.Server( + "https://soroban-testnet.stellar.org", + ); + if (!walletAddress) { setErrorMessage("Please connect your wallet to continue."); return; @@ -83,27 +89,6 @@ export function InvestDialog({ setSubmitting(true); try { - // Ensure USDC trustline exists before buying - const trustlineRes = await axios.post("/api/trustline/add", { - address: walletAddress, - }); - - if (trustlineRes.data?.success && trustlineRes.data?.xdr) { - const signedTrustlineTx = await signTransaction({ - unsignedTransaction: trustlineRes.data.xdr, - address: walletAddress, - }); - const sender = new SendTransactionService(); - const trustlineResult = await sender.sendTransaction({ - signedXdr: signedTrustlineTx, - }); - if (trustlineResult.status !== "SUCCESS") { - throw new Error( - trustlineResult.message ?? "Failed to add USDC trustline.", - ); - } - } - const tokenService = new TokenService(); const payload: BuyTokenPayload = { @@ -127,25 +112,23 @@ export function InvestDialog({ address: walletAddress, }); - const sender = new SendTransactionService(); - const submitResponse = await sender.sendTransaction({ - signedXdr: signedTxXdr, - }); + const tx = TransactionBuilder.fromXDR(signedTxXdr ?? "", Networks.TESTNET); - if (submitResponse.status !== "SUCCESS") { + const send = await server.sendTransaction(tx); + if (send.status === "ERROR") { throw new Error( - submitResponse.message ?? "Transaction submission failed." + `Soroban error: ${JSON.stringify(send.errorResult)}`, ); } - if (selected.campaignId && submitResponse.hash) { + if (selected.campaignId && send.hash) { try { await createInvestment({ campaignId: selected.campaignId, investorAddress: walletAddress, usdcAmount: values.amount, tokenAmount: values.amount, - txHash: submitResponse.hash, + txHash: send.hash ?? "", }); } catch (dbError) { console.error("Failed to save investment to database:", dbError); @@ -228,131 +211,131 @@ export function InvestDialog({ Invest
- - ( - - - Amount (USDC) - - -
- { - const next = - e.target.value === "" - ? ("" as unknown as number) - : Number(e.target.value); - field.onChange(next); - }} - /> - - USDC - -
-
-

- Available balance:{" "} - - {totalAmount > 0 - ? `${totalAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })} ${currency}` - : `0.00 ${currency}`} + + ( + + + Amount (USDC) + + +

+ { + const next = + e.target.value === "" + ? ("" as unknown as number) + : Number(e.target.value); + field.onChange(next); + }} + /> + + USDC -

- - - )} - /> - -
-
- - Estimated Yield - -

- {expectedReturn}% APY -

-
-
- - Term Length - -

- {loanDuration} Months +

+ +

+ Available balance:{" "} + + {totalAmount > 0 + ? `${totalAmount.toLocaleString("en-US", { minimumFractionDigits: 2 })} ${currency}` + : `0.00 ${currency}`} +

-
-
- -
-
- Your investment - - {safeAmount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {currency} - -
-
- - Estimated return ({expectedReturn}% × {loanDuration}mo) - - - +{estimatedReturn.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {currency} - -
-
- Total at maturity - - {totalAtMaturity.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {currency} - -
+ + + )} + /> + +
+
+ + Estimated Yield + +

+ {expectedReturn}% APY +

- -
- -

- Disclaimer: Please - review your investment amount carefully. Once confirmed, these - amounts are not editable and the transaction is final. +

+ + Term Length + +

+ {loanDuration} Months

+
+ +
+
+ Your investment + + {safeAmount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {currency} + +
+
+ + Estimated return ({expectedReturn}% × {loanDuration}mo) + + + +{estimatedReturn.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {currency} + +
+
+ Total at maturity + + {totalAtMaturity.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {currency} + +
+
+ +
+ +

+ Disclaimer: Please + review your investment amount carefully. Once confirmed, these + amounts are not editable and the transaction is final. +

+
- {errorMessage ? ( -

- {errorMessage} -

- ) : null} - - - -

- By clicking confirm, you agree to the Terms of Service and - Investment Agreement. + {errorMessage ? ( +

+ {errorMessage}

- - + ) : null} + + + +

+ By clicking confirm, you agree to the Terms of Service and + Investment Agreement. +

+ + ); diff --git a/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx b/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx index 9f99180..0ff67ef 100644 --- a/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx +++ b/apps/investor-tokenization/src/features/transparency/ProjectCard.tsx @@ -5,7 +5,7 @@ import { Badge } from "@tokenization/ui/badge"; import { Button } from "@tokenization/ui/button"; import { CampaignCard as SharedCampaignCard } from "@tokenization/ui/campaign-card"; import { cn } from "@tokenization/shared/lib/utils"; -import { CheckCircle, Circle, ExternalLink, Rocket } from "lucide-react"; +import { Banknote, CheckCircle, Circle, ExternalLink, Rocket } from "lucide-react"; import type { GetEscrowsFromIndexerResponse as Escrow, MultiReleaseMilestone, @@ -22,15 +22,17 @@ export type ProjectCardProps = { isLoading?: boolean; }; +function getVisibleMilestones(escrow: Escrow | undefined): MultiReleaseMilestone[] { + if (!escrow?.milestones) return []; + return (escrow.milestones as MultiReleaseMilestone[]).slice(1); +} + function getLoansCompleted(escrow: Escrow | undefined): number { - if (!escrow?.milestones) return 0; - const milestones = escrow.milestones as MultiReleaseMilestone[]; - return milestones.filter((m) => m.status === "Approved").length; + return getVisibleMilestones(escrow).filter((m) => m.status === "Approved").length; } function getTotalMilestones(escrow: Escrow | undefined): number { - if (!escrow?.milestones) return 0; - return (escrow.milestones as MultiReleaseMilestone[]).length; + return getVisibleMilestones(escrow).length; } function getProgress(escrow: Escrow | undefined): number { @@ -83,12 +85,26 @@ export const ProjectCard = ({ title={`#${campaign.id.slice(0, 3).toUpperCase()} ${name}`} description={description || "No description"} statusBadge={ - - {statusCfg.label} - + <> +
+ + {statusCfg.label} + + + +
+ } actions={ tokenSaleId ? ( @@ -117,42 +133,36 @@ export const ProjectCard = ({ footer={
- USDC {assigned.toLocaleString("en-US", { minimumFractionDigits: 2 })} / USDC {poolSize.toLocaleString("en-US", { minimumFractionDigits: 2 })} + Pool Size: USDC {assigned.toLocaleString("en-US", { minimumFractionDigits: 2 })} / USDC {poolSize.toLocaleString("en-US", { minimumFractionDigits: 2 })} - {campaign.vaultId && ( - - Vault: {campaign.vaultId.slice(0, 8)}...{campaign.vaultId.slice(-4)} - - )} -
} progress={{ label: "Loans Completed", value: progress }} > - {milestones.length > 0 ? ( -
    - {milestones.map((m, i) => ( -
  • - {m.status === "Approved" ? ( - - ) : ( - - )} - {m.description || `Milestone ${i + 1}`} - {fromStroops(m.amount ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2 })} USDC -
  • - ))} -
- ) : null} + {milestones.slice(1).length > 0 ? ( + <> +

+ Loans +

+
    + {milestones.slice(1).map((m, i) => ( +
  • + {m.flags?.approved ? ( + + ) : m.flags?.released ? ( + + ) : ( + + )} + {m.description || `Loan ${i + 1}`} + {m.amount} USDC +
  • + ))} +
+ + ) : ( +

No loans available.

+ )} ); }; diff --git a/apps/investor-tokenization/src/lib/httpClient.ts b/apps/investor-tokenization/src/lib/httpClient.ts index f91c1c2..5b47e77 100644 --- a/apps/investor-tokenization/src/lib/httpClient.ts +++ b/apps/investor-tokenization/src/lib/httpClient.ts @@ -1,6 +1,6 @@ import { createHttpClient } from "@tokenization/shared/lib/httpClient"; export const httpClient = createHttpClient({ - baseURL: "/core-api", + baseURL: process.env.NEXT_PUBLIC_CORE_API_URL ?? "http://localhost:4000", apiKey: process.env.NEXT_PUBLIC_INVESTORS_API_KEY ?? "", }); diff --git a/packages/shared/src/lib/sendTransactionService.ts b/packages/shared/src/lib/sendTransactionService.ts index 7112bff..22e2ae7 100644 --- a/packages/shared/src/lib/sendTransactionService.ts +++ b/packages/shared/src/lib/sendTransactionService.ts @@ -11,41 +11,59 @@ export type SendTransactionResponse = { }; export type SendTransactionServiceOptions = { - /** - * When omitted: - * - prefers NEXT_PUBLIC_API_URL (useful when calling an external API) - * - falls back to "/api" (useful when using Next route handlers) - */ + /** 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; }; export class SendTransactionService { private readonly axios: AxiosInstance; - private readonly baseURL: string; constructor(options: SendTransactionServiceOptions = {}) { - // If NEXT_PUBLIC_API_URL is set, use it. Otherwise, use relative path /api - // This allows the service to work both with external APIs and Next.js route handlers - const envApiUrl = process.env.NEXT_PUBLIC_API_URL; - this.baseURL = options.baseURL ?? (envApiUrl && envApiUrl.trim() !== "" ? envApiUrl : "/api"); + const envApiUrl = + typeof process !== "undefined" && process.env?.NEXT_PUBLIC_CORE_API_URL; + const baseURL = + options.baseURL ?? + (envApiUrl && String(envApiUrl).trim() !== "" ? envApiUrl : "/api"); + + const headers: Record = {}; + 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() || + "" + : "") || + // 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() || + ""; + + if (apiKey !== "") { + headers["x-api-key"] = apiKey; + } this.axios = axios.create({ - baseURL: this.baseURL, + baseURL, + headers, }); } async sendTransaction( payload: SendTransactionPayload ): Promise { - // Log for debugging - console.log("SendTransactionService: baseURL =", this.baseURL); - console.log("SendTransactionService: full URL will be", `${this.baseURL}/helper/send-transaction`); - const response = await this.axios.post( "/helper/send-transaction", payload ); - return response.data; } } diff --git a/packages/ui/src/app-sidebar.tsx b/packages/ui/src/app-sidebar.tsx index 7ce7ca4..6996074 100644 --- a/packages/ui/src/app-sidebar.tsx +++ b/packages/ui/src/app-sidebar.tsx @@ -82,7 +82,7 @@ export function AppSidebar({