diff --git a/apps/backend/src/modules/auth/controllers/auth.controller.ts b/apps/backend/src/modules/auth/controllers/auth.controller.ts index c43f625..e3f8852 100644 --- a/apps/backend/src/modules/auth/controllers/auth.controller.ts +++ b/apps/backend/src/modules/auth/controllers/auth.controller.ts @@ -7,10 +7,15 @@ import { Req, HttpCode, HttpStatus, + Param, + BadRequestException, + NotFoundException, } from '@nestjs/common'; import { Request } from 'express'; import { ThrottlerGuard } from '@nestjs/throttler'; +import { StrKey } from '@stellar/stellar-sdk'; import { AuthService } from '../services/auth.service'; +import { UserService } from '../../user/user.service'; import { ChallengeDto, VerifyDto, @@ -22,7 +27,28 @@ import { AuthGuard } from '../middleware/auth.guard'; @Controller('auth') @UseGuards(ThrottlerGuard) export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly userService: UserService, + ) {} + + /** + * Resolve a Vaultix user id by Stellar wallet address (counterparty must have signed in once). + */ + @Get('wallet/:address') + @UseGuards(AuthGuard) + async getUserByWallet(@Param('address') address: string) { + if (!StrKey.isValidEd25519PublicKey(address)) { + throw new BadRequestException('Invalid Stellar address'); + } + const user = await this.userService.findByWalletAddress(address); + if (!user) { + throw new NotFoundException( + 'No Vaultix account exists for this wallet address', + ); + } + return { id: user.id, walletAddress: user.walletAddress }; + } @Post('challenge') @HttpCode(HttpStatus.OK) diff --git a/apps/backend/src/modules/auth/middleware/auth.guard.ts b/apps/backend/src/modules/auth/middleware/auth.guard.ts index fd9ea0c..68defe4 100644 --- a/apps/backend/src/modules/auth/middleware/auth.guard.ts +++ b/apps/backend/src/modules/auth/middleware/auth.guard.ts @@ -21,7 +21,10 @@ export class AuthGuard implements CanActivate { try { const payload = await this.authService.validateToken(token); - request['user'] = payload; + request['user'] = { + ...payload, + sub: payload.userId, + }; return true; } catch { throw new UnauthorizedException('Invalid or expired token'); diff --git a/apps/backend/src/modules/escrow/controllers/escrow.controller.ts b/apps/backend/src/modules/escrow/controllers/escrow.controller.ts index 6ea4fb2..4dca56b 100644 --- a/apps/backend/src/modules/escrow/controllers/escrow.controller.ts +++ b/apps/backend/src/modules/escrow/controllers/escrow.controller.ts @@ -89,6 +89,22 @@ export class EscrowController { return this.escrowService.findOverview(userId, query); } + @Get(':id/fund/prepare') + @UseGuards(EscrowAccessGuard) + @ApiOperation({ + summary: 'Get unsigned funding transaction XDR for wallet signing', + }) + async prepareFund( + @Param('id') id: string, + @Request() req: AuthenticatedRequest, + ) { + return this.escrowService.prepareFund( + id, + req.user.sub, + req.user.walletAddress, + ); + } + @Get(':id') @UseGuards(EscrowAccessGuard) async findOne(@Param('id') id: string) { diff --git a/apps/backend/src/modules/escrow/dto/fund-escrow.dto.ts b/apps/backend/src/modules/escrow/dto/fund-escrow.dto.ts index cd2e6ab..799c72e 100644 --- a/apps/backend/src/modules/escrow/dto/fund-escrow.dto.ts +++ b/apps/backend/src/modules/escrow/dto/fund-escrow.dto.ts @@ -1,7 +1,12 @@ -import { IsNumber, IsPositive } from 'class-validator'; +import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; export class FundEscrowDto { @IsNumber() @IsPositive() amount: number; + + /** Base64-encoded signed transaction envelope (wallet-signed funding tx). */ + @IsOptional() + @IsString() + signedTransactionXdr?: string; } diff --git a/apps/backend/src/modules/escrow/services/escrow-stellar-integration.service.ts b/apps/backend/src/modules/escrow/services/escrow-stellar-integration.service.ts index 1a42508..120a10e 100644 --- a/apps/backend/src/modules/escrow/services/escrow-stellar-integration.service.ts +++ b/apps/backend/src/modules/escrow/services/escrow-stellar-integration.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; import { ConfigType } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import * as StellarSdk from '@stellar/stellar-sdk'; import { Escrow } from '../entities/escrow.entity'; import { Party } from '../entities/party.entity'; import { Condition } from '../entities/condition.entity'; @@ -42,7 +43,7 @@ export class EscrowStellarIntegrationService { // Get the escrow from the database const escrow = await this.escrowRepository.findOne({ where: { id: escrowId }, - relations: ['parties', 'conditions'], + relations: ['parties', 'parties.user', 'conditions'], }); if (!escrow) { @@ -51,20 +52,28 @@ export class EscrowStellarIntegrationService { // Get the depositor (usually the buyer) const depositor = escrow.parties.find( - (party) => party.role === ('buyer' as any), + (party) => party.role === ('buyer' as any) ); if (!depositor) { throw new Error(`Depositor not found for escrow ${escrowId}`); } + if (!depositor.user?.walletAddress) { + throw new Error(`Depositor wallet address not found for escrow ${escrowId}`); + } + // Get the recipient (usually the seller) const recipient = escrow.parties.find( - (party) => party.role === ('seller' as any), + (party) => party.role === ('seller' as any) ); if (!recipient) { throw new Error(`Recipient not found for escrow ${escrowId}`); } + if (!recipient.user?.walletAddress) { + throw new Error(`Recipient wallet address not found for escrow ${escrowId}`); + } + // Convert conditions to milestones format const milestones = escrow.conditions.map((condition, index) => ({ id: index, @@ -78,18 +87,18 @@ export class EscrowStellarIntegrationService { const operations = this.escrowOperationsService.createEscrowInitializationOps( escrowId, - depositor.user.walletAddress, // User's Stellar wallet address - recipient.user.walletAddress, // User's Stellar wallet address - 'native', // Using XLM as the asset for this example + depositor.user.walletAddress, + recipient.user.walletAddress, + 'native', milestones, escrow.expiresAt ? Math.floor(new Date(escrow.expiresAt).getTime() / 1000) - : Math.floor(Date.now() / 1000) + 86400, // Convert to Unix timestamp or default to 24 hours + : Math.floor(Date.now() / 1000) + 86400, ); // Build the transaction const transaction = await this.stellarService.buildTransaction( - depositor.user.walletAddress, // Source account + depositor.user.walletAddress, operations, ); @@ -110,13 +119,48 @@ export class EscrowStellarIntegrationService { } /** - * Funds an escrow on the Stellar blockchain - * @param escrowId The ID of the escrow to fund - * @param funderPublicKey The public key of the account funding the escrow - * @param amount The amount to fund - * @param assetCode The asset code (e.g., 'XLM' or custom asset) - * @returns Transaction hash of the funding transaction + * Builds an unsigned funding transaction (for client-side wallet signing). */ + async buildFundOnChainEscrowTransaction( + escrowId: string, + funderPublicKey: string, + amount: string, + assetCode: string = 'XLM', + ) { + this.logger.log( + `Building funding transaction for escrow ${escrowId}, amount: ${amount} ${assetCode}`, + ); + + const asset = this.getAssetFromCode(assetCode, funderPublicKey); + + const operations = this.escrowOperationsService.createFundingOps( + escrowId, + funderPublicKey, + amount, + asset, + ); + + return this.stellarService.buildTransaction( + funderPublicKey, + operations, + ); + } + + async prepareFundTransactionXdr( + escrowId: string, + funderPublicKey: string, + amount: string, + assetCode: string = 'XLM', + ): Promise { + const tx = await this.buildFundOnChainEscrowTransaction( + escrowId, + funderPublicKey, + amount, + assetCode, + ); + return tx.toXDR(); + } + async fundOnChainEscrow( escrowId: string, funderPublicKey: string, @@ -128,23 +172,13 @@ export class EscrowStellarIntegrationService { `Funding on-chain escrow ${escrowId} with ${amount} ${assetCode}`, ); - // Determine asset (unused but kept logic if needed later, currently causing lint error) - // const asset = - // assetCode === 'XLM' || assetCode === 'native' - // ? StellarSdk.Asset.native() - // : new StellarSdk.Asset(assetCode, funderPublicKey); - - // Create funding operations - const operations = - this.escrowOperationsService.createFundingOps(escrowId); - - // Build the transaction - const transaction = await this.stellarService.buildTransaction( - funderPublicKey, // Source account - operations, + const transaction = await this.buildFundOnChainEscrowTransaction( + escrowId, + funderPublicKey, + amount, + assetCode, ); - // Submit the transaction to the Stellar network const result: StellarSubmitTransactionResponse = await this.stellarService.submitTransaction(transaction); @@ -174,24 +208,31 @@ export class EscrowStellarIntegrationService { escrowId: string, milestoneId: number, releaserPublicKey: string, - // recipientPublicKey: string, - // amount: string, - // assetCode: string = 'XLM', + recipientPublicKey: string, + amount: string, + assetCode: string = 'XLM', ): Promise { try { this.logger.log( `Releasing milestone ${milestoneId} for escrow ${escrowId}`, ); + // Get the asset + const asset = this.getAssetFromCode(assetCode, recipientPublicKey); + // Create milestone release operations const operations = this.escrowOperationsService.createMilestoneReleaseOps( escrowId, milestoneId, + releaserPublicKey, + recipientPublicKey, + amount, + asset, ); // Build the transaction const transaction = await this.stellarService.buildTransaction( - releaserPublicKey, // Source account + releaserPublicKey, operations, ); @@ -215,29 +256,29 @@ export class EscrowStellarIntegrationService { * Confirms delivery/acceptance of an escrow on the Stellar blockchain * @param escrowId The ID of the escrow to confirm * @param confirmerPublicKey The public key of the account confirming - * @param milestoneId The milestone ID + * @param confirmationStatus The status of the confirmation * @returns Transaction hash of the confirmation transaction */ async confirmEscrow( escrowId: string, confirmerPublicKey: string, - milestoneId: number, + confirmationStatus: 'confirmed' | 'disputed' | 'released' = 'confirmed', ): Promise { try { this.logger.log( - `Confirming escrow ${escrowId} for milestone: ${milestoneId}`, + `Confirming escrow ${escrowId} with status: ${confirmationStatus}`, ); // Create confirmation operations const operations = this.escrowOperationsService.createConfirmationOps( escrowId, confirmerPublicKey, - milestoneId, + confirmationStatus, ); // Build the transaction const transaction = await this.stellarService.buildTransaction( - confirmerPublicKey, // Source account + confirmerPublicKey, operations, ); @@ -246,7 +287,7 @@ export class EscrowStellarIntegrationService { await this.stellarService.submitTransaction(transaction); this.logger.log( - `Successfully confirmed milestone ${milestoneId} for escrow ${escrowId}, transaction: ${result.hash}`, + `Successfully confirmed escrow ${escrowId} with status ${confirmationStatus}, transaction: ${result.hash}`, ); return result.hash; } catch (error) { @@ -261,24 +302,24 @@ export class EscrowStellarIntegrationService { * Cancels an escrow on the Stellar blockchain * @param escrowId The ID of the escrow to cancel * @param cancellerPublicKey The public key of the account canceling - * @param refundDestination The destination for refunded funds * @returns Transaction hash of the cancellation transaction */ async cancelOnChainEscrow( escrowId: string, cancellerPublicKey: string, - - // refundDestination: string, ): Promise { try { this.logger.log(`Canceling on-chain escrow ${escrowId}`); // Create cancel operations - const operations = this.escrowOperationsService.createCancelOps(escrowId); + const operations = this.escrowOperationsService.createCancelOps( + escrowId, + cancellerPublicKey, + ); // Build the transaction const transaction = await this.stellarService.buildTransaction( - cancellerPublicKey, // Source account + cancellerPublicKey, operations, ); @@ -312,12 +353,14 @@ export class EscrowStellarIntegrationService { this.logger.log(`Completing on-chain escrow ${escrowId}`); // Create completion operations - const operations = - this.escrowOperationsService.createCompletionOps(escrowId); + const operations = this.escrowOperationsService.createCompletionOps( + escrowId, + completerPublicKey, + ); // Build the transaction const transaction = await this.stellarService.buildTransaction( - completerPublicKey, // Source account + completerPublicKey, operations, ); @@ -342,12 +385,14 @@ export class EscrowStellarIntegrationService { * @param escrowId The ID of the escrow to monitor * @param accountPublicKey The public key of the account to monitor * @param callback Callback function to handle state changes + * @param onError Optional error callback * @returns EventSource object for stream control */ monitorOnChainEscrow( escrowId: string, accountPublicKey: string, callback: (transaction: StellarTransactionResponse) => void, + onError?: (error: Error) => void, ): EventSource { this.logger.log( `Starting to monitor on-chain escrow ${escrowId} for account: ${accountPublicKey}`, @@ -355,26 +400,58 @@ export class EscrowStellarIntegrationService { // Create a wrapper callback that filters for our escrow-related transactions const filteredCallback = (transaction: StellarTransactionResponse) => { - // Check if this transaction relates to our escrow - const isEscrowRelated = - transaction.memo && - typeof transaction.memo === 'string' && - transaction.memo.includes(escrowId); - - if (isEscrowRelated) { - this.logger.log( - `Detected escrow ${escrowId} related transaction: ${transaction.hash}`, + try { + // Check if this transaction relates to our escrow + const isEscrowRelated = + transaction.memo && + typeof transaction.memo === 'string' && + transaction.memo.includes(escrowId); + + if (isEscrowRelated) { + this.logger.log( + `Detected escrow ${escrowId} related transaction: ${transaction.hash}`, + ); + callback(transaction); + } + } catch (error) { + this.logger.error( + `Error processing transaction for escrow ${escrowId}: ${this.getErrorMessage(error)}`, ); - callback(transaction); + if (onError) { + onError(error instanceof Error ? error : new Error(String(error))); + } } }; // Stream transactions for the account - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this.stellarService.streamTransactions( - accountPublicKey, - filteredCallback, - ); + try { + return this.stellarService.streamTransactions( + accountPublicKey, + filteredCallback, + ) as unknown as EventSource; + } catch (error) { + this.logger.error( + `Failed to start monitoring escrow ${escrowId}: ${this.getErrorMessage(error)}`, + ); + throw error; + } + } + + /** + * Helper method to get Stellar asset from code and issuer + */ + private getAssetFromCode(assetCode: string, issuerPublicKey?: string): StellarSdk.Asset { + // Handle native asset (XLM) + if (assetCode === 'XLM' || assetCode === 'native') { + return StellarSdk.Asset.native(); + } + + // For custom assets, issuer is required + if (!issuerPublicKey) { + throw new Error(`Issuer public key is required for asset ${assetCode}`); + } + + return new StellarSdk.Asset(assetCode, issuerPublicKey); } /** @@ -385,9 +462,19 @@ export class EscrowStellarIntegrationService { return error.message; } if (typeof error === 'object' && error !== null && 'message' in error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return String((error as any).message); + const err = error as { message: unknown }; + if (typeof err.message === 'string') { + return err.message; + } + if (typeof err.message === 'object' && err.message !== null) { + try { + return JSON.stringify(err.message); + } catch { + return '[Object]'; + } + } + return String(err.message); } - return 'Unknown error'; + return String(error); } -} +} \ No newline at end of file diff --git a/apps/backend/src/modules/escrow/services/escrow.service.spec.ts b/apps/backend/src/modules/escrow/services/escrow.service.spec.ts index 3b6e1c7..8552758 100644 --- a/apps/backend/src/modules/escrow/services/escrow.service.spec.ts +++ b/apps/backend/src/modules/escrow/services/escrow.service.spec.ts @@ -22,6 +22,7 @@ import { } from '@nestjs/common'; import { EscrowStellarIntegrationService } from './escrow-stellar-integration.service'; import { WebhookService } from '../../../services/webhook/webhook.service'; +import { StellarService } from '../../../services/stellar.service'; import { EscrowOverviewRole, EscrowOverviewSortBy, @@ -127,6 +128,17 @@ describe('EscrowService', () => { useValue: { completeOnChainEscrow: jest.fn().mockResolvedValue('mock-tx-hash'), fundOnChainEscrow: jest.fn().mockResolvedValue('mock-fund-tx-hash'), + prepareFundTransactionXdr: jest + .fn() + .mockResolvedValue('mock-unsigned-xdr'), + }, + }, + { + provide: StellarService, + useValue: { + submitSignedTransactionXdr: jest + .fn() + .mockResolvedValue({ hash: 'signed-submit-hash' }), }, }, { diff --git a/apps/backend/src/modules/escrow/services/escrow.service.ts b/apps/backend/src/modules/escrow/services/escrow.service.ts index e978d21..469b8fa 100644 --- a/apps/backend/src/modules/escrow/services/escrow.service.ts +++ b/apps/backend/src/modules/escrow/services/escrow.service.ts @@ -38,6 +38,7 @@ import { ExpireEscrowDto } from '../dto/expire-escrow.dto'; import { validateTransition, isTerminalStatus } from '../escrow-state-machine'; import { EscrowStellarIntegrationService } from './escrow-stellar-integration.service'; import { WebhookService } from '../../../services/webhook/webhook.service'; +import { StellarService } from '../../../services/stellar.service'; import { User, UserRole } from '../../user/entities/user.entity'; @Injectable() @@ -57,6 +58,7 @@ export class EscrowService { private userRepository: Repository, private readonly stellarIntegrationService: EscrowStellarIntegrationService, + private readonly stellarService: StellarService, private readonly webhookService: WebhookService, ) {} @@ -432,6 +434,42 @@ export class EscrowService { }); } + /** + * Returns an unsigned funding transaction (XDR) for the buyer to sign in their wallet. + */ + async prepareFund( + id: string, + userId: string, + walletAddress: string, + ): Promise<{ transactionXdr: string }> { + const escrow = await this.findOne(id); + + if (escrow.creatorId !== userId) { + throw new ForbiddenException('Only the buyer can fund this escrow'); + } + + if (escrow.status !== EscrowStatus.PENDING) { + throw new BadRequestException( + 'Escrow can only be funded while in pending status', + ); + } + + if (escrow.stellarTxHash) { + throw new BadRequestException('Escrow is already funded'); + } + + const escrowAmount = Number(escrow.amount); + const transactionXdr = + await this.stellarIntegrationService.prepareFundTransactionXdr( + id, + walletAddress, + String(escrowAmount), + escrow.asset ?? 'XLM', + ); + + return { transactionXdr }; + } + async fund( id: string, dto: FundEscrowDto, @@ -462,13 +500,19 @@ export class EscrowService { validateTransition(escrow.status, EscrowStatus.ACTIVE); - const stellarTxHash = - await this.stellarIntegrationService.fundOnChainEscrow( - id, - walletAddress, - String(dto.amount), - escrow.asset ?? 'XLM', - ); + const stellarTxHash = dto.signedTransactionXdr + ? ( + await this.stellarService.submitSignedTransactionXdr( + dto.signedTransactionXdr, + walletAddress, + ) + ).hash + : await this.stellarIntegrationService.fundOnChainEscrow( + id, + walletAddress, + String(dto.amount), + escrow.asset ?? 'XLM', + ); const fundedAt = new Date(); await this.escrowRepository.update(id, { @@ -1093,4 +1137,4 @@ export class EscrowService { return this.findOne(escrow.id); } -} +} \ No newline at end of file diff --git a/apps/backend/src/services/stellar.service.ts b/apps/backend/src/services/stellar.service.ts index ccaf234..71cc84d 100644 --- a/apps/backend/src/services/stellar.service.ts +++ b/apps/backend/src/services/stellar.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; import { ConfigType } from '@nestjs/config'; import stellarConfig from '../config/stellar.config'; import * as StellarSdk from '@stellar/stellar-sdk'; +import { BadRequestException } from '@nestjs/common'; import { retryWithBackoff } from '../utils/retry.util'; import { StellarAccountResponse, @@ -116,6 +117,50 @@ export class StellarService { * @param transaction The transaction object to submit * @returns Transaction result */ + /** + * Submits a wallet-signed transaction envelope (base64 XDR). + * Verifies the transaction source matches the authenticated wallet. + */ + async submitSignedTransactionXdr( + signedXdr: string, + expectedSourceAccount: string, + ): Promise { + try { + const parsed = StellarSdk.TransactionBuilder.fromXDR( + signedXdr, + this.networkPassphrase, + ); + + const FeeBump = (StellarSdk as unknown as { FeeBumpTransaction?: abstract new () => unknown }) + .FeeBumpTransaction; + if (FeeBump && parsed instanceof FeeBump) { + throw new BadRequestException('Fee bump transactions are not supported'); + } + + const tx = parsed as StellarSdk.Transaction; + const source = tx.source; + + if (!source || source !== expectedSourceAccount) { + throw new BadRequestException( + 'Signed transaction source does not match your wallet address', + ); + } + + return this.submitTransaction(tx); + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + this.logger.error( + `Failed to parse or submit signed XDR: ${this.getErrorMessage(error)}`, + ); + throw this.mapStellarError( + error, + 'Invalid or failed signed transaction submission', + ); + } + } + async submitTransaction( transaction: StellarSdk.Transaction, ): Promise { diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs new file mode 100644 index 0000000..7c83a3e --- /dev/null +++ b/apps/frontend/.eslintrc.cjs @@ -0,0 +1,9 @@ +module.exports = { + root: true, + extends: ["next/core-web-vitals", "next/typescript"], + rules: { + // Existing codebase uses `any` in many places; keep lint actionable. + "@typescript-eslint/no-explicit-any": "off", + }, +}; + diff --git a/apps/frontend/component/Providers.tsx b/apps/frontend/component/Providers.tsx index 4131a79..9baeeea 100644 --- a/apps/frontend/component/Providers.tsx +++ b/apps/frontend/component/Providers.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ToastProvider } from '@/app/contexts/ToastProvider'; +import { WalletProvider } from '@/app/contexts/WalletContext'; export default function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient({ @@ -16,9 +17,9 @@ export default function Providers({ children }: { children: React.ReactNode }) { return ( - - {children} - + + {children} + ); } \ No newline at end of file diff --git a/apps/frontend/component/escrow/CreateEscrowWizard.tsx b/apps/frontend/component/escrow/CreateEscrowWizard.tsx index 73ff35e..9a6d886 100644 --- a/apps/frontend/component/escrow/CreateEscrowWizard.tsx +++ b/apps/frontend/component/escrow/CreateEscrowWizard.tsx @@ -1,18 +1,18 @@ 'use client'; -import { useState } from 'react'; -import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { useForm, FormProvider } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; import { createEscrowSchema, CreateEscrowFormData } from '@/lib/escrow-schema'; import BasicInfoStep from './create/BasicInfoStep'; import PartiesStep from './create/PartiesStep'; import TermsStep from './create/TermsStep'; import ReviewStep from './create/ReviewStep'; import { CheckCircle2, ChevronRight, ChevronLeft, Loader2, AlertCircle } from 'lucide-react'; -import { isConnected, signTransaction, getAddress } from '@stellar/freighter-api'; -import { Horizon, Networks, TransactionBuilder, Account, Asset, Operation } from 'stellar-sdk'; +import { getAddress, isConnected, signTransaction } from '@stellar/freighter-api'; +import { apiRequest, explorerTxUrl } from '@/lib/api-client'; +import { useWalletConnection } from '@/app/hooks/useWallet'; const STEPS = [ { id: 'basic', title: 'Basic Info', fields: ['title', 'description', 'category'] }, @@ -22,9 +22,14 @@ const STEPS = [ ]; export default function CreateEscrowWizard() { + const router = useRouter(); + const { network: connectedNetwork } = useWalletConnection(); const [currentStep, setCurrentStep] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const [txHash, setTxHash] = useState(null); + const [createdEscrowId, setCreatedEscrowId] = useState(null); + const [explorerUrl, setExplorerUrl] = useState(null); + const [isWalletSigning, setIsWalletSigning] = useState(false); const [submitError, setSubmitError] = useState(null); const methods = useForm({ @@ -35,7 +40,23 @@ export default function CreateEscrowWizard() { } }); - const { trigger, handleSubmit, getValues } = methods; + const { trigger, handleSubmit } = methods; + + const parseErrorMessage = (error: unknown): string => { + if (error && typeof error === 'object' && 'message' in error) { + const message = (error as { message?: string }).message; + if (message) return message; + } + return 'Failed to create escrow. Please try again.'; + }; + + useEffect(() => { + if (!txHash || !createdEscrowId) return; + const timer = window.setTimeout(() => { + router.push(`/escrow/${createdEscrowId}`); + }, 1800); + return () => window.clearTimeout(timer); + }, [txHash, createdEscrowId, router]); const nextStep = async () => { const fields = STEPS[currentStep].fields as any[]; @@ -54,10 +75,11 @@ export default function CreateEscrowWizard() { const onSubmit = async (data: CreateEscrowFormData) => { setIsSubmitting(true); + setIsWalletSigning(false); setSubmitError(null); try { - // 1. Check Wallet Connection + // 1) Wallet connection check const connected = await isConnected(); if (!connected) { throw new Error('Freighter wallet not connected. Please install and connect Freighter.'); @@ -68,40 +90,87 @@ export default function CreateEscrowWizard() { throw new Error('Could not retrieve address from Freighter.'); } - // 2. Build Transaction (Mock/Placeholder logic) - // In a real app, you would fetch the sequence number, build the invokeHostFunction op, etc. - // For this demo, we'll demonstrate the intent. + // 2) Resolve counterparty to an existing Vaultix user + const counterparty = await apiRequest<{ id: string }>( + `/auth/wallet/${data.counterpartyAddress}`, + ); - // Example: - // const server = new Horizon.Server('https://horizon-testnet.stellar.org'); - // const account = await server.loadAccount(publicKey); - // const tx = new TransactionBuilder(account, { - // fee: '100', - // networkPassphrase: Networks.TESTNET, - // }) - // .addOperation(...) // Invoke contract logic here - // .setTimeout(30) - // .build(); + // 3) Create escrow record + const createdEscrow = await apiRequest<{ id: string; amount: number }>( + '/escrows', + { + method: 'POST', + body: JSON.stringify({ + title: data.title, + description: data.description, + amount: Number(data.amount), + asset: data.asset, + expiresAt: data.deadline.toISOString(), + parties: [{ userId: counterparty.id, role: 'seller' }], + }), + }, + ); - // Since we don't have the contract bindings generated, we'll simulate the delay and signing request - // to demonstrate the UX flow. + setCreatedEscrowId(createdEscrow.id); - // await signTransaction(tx.toXDR(), { network: 'TESTNET' }); + // 4) Request unsigned funding tx XDR + const prepared = await apiRequest<{ transactionXdr: string }>( + `/escrows/${createdEscrow.id}/fund/prepare`, + ); - await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate building + // 5) Ask wallet to sign + setIsWalletSigning(true); + const freighterNetwork = connectedNetwork === 'public' ? 'PUBLIC' : 'TESTNET'; + const signedXdrResult = await signTransaction(prepared.transactionXdr, { + networkPassphrase: freighterNetwork, + }); + setIsWalletSigning(false); - // Simulate signing success - // const signedXdr = await signTransaction(mockXdr, ...); + const signedXdr = + typeof signedXdrResult === 'string' + ? signedXdrResult + : (signedXdrResult as { signedTxXdr?: string; signedEnvelopeXdr?: string }); + const signedEnvelope = + typeof signedXdr === 'string' + ? signedXdr + : signedXdr.signedTxXdr || signedXdr.signedEnvelopeXdr; - // Simulate submission - // await server.submitTransaction(transaction); + if (!signedEnvelope) { + throw new Error('Wallet did not return a signed transaction.'); + } + + // 6) Submit signed tx via backend fund endpoint + const funded = await apiRequest<{ id: string; stellarTxHash?: string }>( + `/escrows/${createdEscrow.id}/fund`, + { + method: 'POST', + body: JSON.stringify({ + amount: Number(data.amount), + signedTransactionXdr: signedEnvelope, + }), + }, + ); - setTxHash('7a8b9c...mock_hash...1d2e3f'); // Success state + if (!funded.stellarTxHash) { + throw new Error('Funding succeeded but transaction hash is missing.'); + } - } catch (error: any) { - console.error(error); - setSubmitError(error.message || 'Failed to create escrow. Please try again.'); + const network = connectedNetwork === 'public' ? 'public' : 'testnet'; + setTxHash(funded.stellarTxHash); + setExplorerUrl(explorerTxUrl(funded.stellarTxHash, network)); + } catch (error) { + const message = parseErrorMessage(error); + if (/reject|denied|declined|cancelled|canceled/i.test(message)) { + setSubmitError('Transaction signing was canceled in wallet.'); + } else if (/insufficient/i.test(message)) { + setSubmitError('Insufficient balance to fund this escrow.'); + } else if (/network/i.test(message)) { + setSubmitError('Network error while creating or funding escrow.'); + } else { + setSubmitError(message); + } } finally { + setIsWalletSigning(false); setIsSubmitting(false); } }; @@ -120,10 +189,25 @@ export default function CreateEscrowWizard() {

Transaction Hash

{txHash}

+ {explorerUrl && ( + + View on Stellar Explorer + + )}
- - Return to Dashboard - + +

Redirecting automatically...

); @@ -215,7 +299,7 @@ export default function CreateEscrowWizard() { {isSubmitting ? ( <> - Creating... + {isWalletSigning ? 'Awaiting wallet signature...' : 'Creating...'} ) : ( <> diff --git a/apps/frontend/lib/api-client.ts b/apps/frontend/lib/api-client.ts new file mode 100644 index 0000000..b0478d8 --- /dev/null +++ b/apps/frontend/lib/api-client.ts @@ -0,0 +1,55 @@ +const API_BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_URL?.replace(/\/$/, '') || + 'http://localhost:3000'; + +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +function getAuthToken(): string | null { + if (typeof window === 'undefined') return null; + return ( + window.localStorage.getItem('vaultix_access_token') || + window.localStorage.getItem('accessToken') + ); +} + +export async function apiRequest( + path: string, + init?: RequestInit, +): Promise { + const token = getAuthToken(); + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init?.headers || {}), + }, + }); + + if (!response.ok) { + const errorBody = (await response + .json() + .catch(() => ({ message: 'Request failed' }))) as { + message?: string | string[]; + }; + const message = Array.isArray(errorBody.message) + ? errorBody.message.join(', ') + : errorBody.message || 'Request failed'; + throw new ApiError(message, response.status); + } + + return (await response.json()) as T; +} + +export function explorerTxUrl(txHash: string, network: 'testnet' | 'public') { + const networkPath = network === 'public' ? 'public' : 'testnet'; + return `https://stellar.expert/explorer/${networkPath}/tx/${txHash}`; +} diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index e7d503e..4876258 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -30,6 +30,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "cross-env": "^7.0.3", "eslint": "^8.57.1", "eslint-config-next": "^15.5.9", "shadcn": "^3.8.5", @@ -3970,7 +3971,6 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4044,7 +4044,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -4592,7 +4591,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5588,6 +5586,24 @@ } } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6213,7 +6229,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6383,7 +6398,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10140,7 +10154,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10150,7 +10163,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10163,7 +10175,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -11866,7 +11877,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12173,7 +12183,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6788053..98ab812 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,7 +6,7 @@ "dev": "next dev --turbopack", "build": "next build --turbopack", "start": "next start", - "lint": "eslint ." + "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint ." }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -31,6 +31,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "cross-env": "^7.0.3", "eslint": "^8.57.1", "eslint-config-next": "^15.5.9", "shadcn": "^3.8.5", diff --git a/eslint.config.js b/eslint.config.js index 75b29b3..4ba4ced 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,4 @@ -import { Linter } from "eslint"; - -/** @type {Linter.FlatConfig[]} */ +/** @type {import("eslint").Linter.FlatConfig[]} */ const config = [ { languageOptions: {