diff --git a/src/App.tsx b/src/App.tsx index 5e3a321..0f2a812 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, Outlet } from "react-router-dom"; +import { Routes, Route, Outlet, useLocation } from "react-router-dom"; import { Navbar } from "./components/navbar"; import { Toaster } from "./components/ui/toaster"; import { NewMessageWatcher } from "./components/new-message-watcher"; @@ -14,18 +14,25 @@ import ApprovalsPage from "./pages/ApprovalsPage"; import FreelancersPage from "./pages/FreelancersPage"; import MessagesPage from "./pages/MessagesPage"; import Debugger from "./pages/Debugger"; +import { PanelErrorBoundary } from "./components/panel-error-boundary"; -const AppLayout = () => ( - <> - -
- -
- - - - -); +const AppLayout = () => { + const location = useLocation(); + + return ( + <> + +
+ + + +
+ + + + + ); +}; function App() { return ( diff --git a/src/components/milestone-actions.tsx b/src/components/milestone-actions.tsx index 4cc1bdd..878c137 100644 --- a/src/components/milestone-actions.tsx +++ b/src/components/milestone-actions.tsx @@ -1,4 +1,11 @@ import { useState } from "react"; +import { contractService } from "@/lib/web3/contract-service"; +import { + PendingTransactionError, + getTransactionErrorGuidance, + type TransactionErrorGuidance, + type TransactionPhase, +} from "@/lib/web3/transaction-feedback"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -14,7 +21,6 @@ import { createMilestoneNotification, } from "@/contexts/notification-context"; import { useToast } from "@/hooks/use-toast"; -import { CONTRACTS } from "@/lib/web3/config"; import { CheckCircle2, @@ -55,10 +61,14 @@ export function MilestoneActions({ escrowReleasedAmount, escrowTotalAmount, }: MilestoneActionsProps) { - const { wallet, getContract } = useWeb3(); + const { wallet } = useWeb3(); const { addNotification } = useNotifications(); const { toast } = useToast(); const [isLoading, setIsLoading] = useState(false); + const [txPhase, setTxPhase] = useState("idle"); + const [txHash, setTxHash] = useState(null); + const [txError, setTxError] = useState(null); + const [retryAllowed, setRetryAllowed] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [actionType, setActionType] = useState< | "start" @@ -92,6 +102,10 @@ export function MilestoneActions({ milestone.status === "disputed" || escrowStatus === "disputed"; const openDialog = (type: typeof actionType) => { + setTxPhase("idle"); + setTxHash(null); + setTxError(null); + setRetryAllowed(false); setActionType(type); setDialogOpen(true); }; @@ -100,36 +114,33 @@ export function MilestoneActions({ if (!actionType) return; setIsLoading(true); - const contract = getContract(CONTRACTS.SECUREFLOW_ESCROW); + setTxError(null); + setRetryAllowed(false); + setTxPhase("building"); + contractService.setTransactionProgressListener(({ phase, txHash: nextHash }) => { + setTxPhase(phase); + if (nextHash) setTxHash(nextHash); + }); try { let txHash: string | undefined; switch (actionType) { case "start": - txHash = await contract.send( - "start_work", + txHash = await contractService.startWork( Number(escrowId), - wallet.address, + wallet.address || "", ); break; case "submit": - txHash = await contract.send( - "submit_milestone", - Number(escrowId), - milestoneIndex, - milestone.description, - wallet.address, - ); + txHash = await contractService.submitMilestone({ + escrow_id: Number(escrowId), + milestone_index: milestoneIndex, + description: milestone.description, + beneficiary: wallet.address || "", + }); break; case "approve": - // Use ContractService instead of contract.send - it handles the correct format - const { ContractService } = await import( - "@/lib/web3/contract-service" - ); - const contractService = new ContractService( - CONTRACTS.SECUREFLOW_ESCROW, - ); txHash = await contractService.approveMilestone({ escrow_id: Number(escrowId), milestone_index: milestoneIndex, @@ -137,52 +148,38 @@ export function MilestoneActions({ }); break; case "reject": - // Use ContractService instead of contract.send - it handles the correct format - const { ContractService: RejectContractService } = await import( - "@/lib/web3/contract-service" - ); - const rejectContractService = new RejectContractService( - CONTRACTS.SECUREFLOW_ESCROW, - ); - txHash = await rejectContractService.rejectMilestone({ + txHash = await contractService.rejectMilestone({ escrow_id: Number(escrowId), milestone_index: milestoneIndex, reason: disputeReason, depositor: wallet.address || "", }); break; - case "dispute": - // Use ContractService instead of contract.send - it handles the correct format - // Determine who can dispute: either payer (client) or beneficiary (freelancer) + case "dispute": { const disputerAddress = wallet.address || ""; if (!disputerAddress) { throw new Error("Wallet address is required to dispute milestone"); } - const { ContractService: DisputeContractService } = await import( - "@/lib/web3/contract-service" - ); - const disputeContractService = new DisputeContractService( - CONTRACTS.SECUREFLOW_ESCROW, - ); - txHash = await disputeContractService.disputeMilestone({ + txHash = await contractService.disputeMilestone({ escrow_id: Number(escrowId), milestone_index: milestoneIndex, reason: disputeReason, disputer: disputerAddress, }); break; + } case "resubmit": - txHash = await contract.send( - "submit_milestone", - Number(escrowId), - milestoneIndex, - resubmitMessage || milestone.description, - wallet.address, - ); + txHash = await contractService.resubmitMilestone({ + escrow_id: Number(escrowId), + milestone_index: milestoneIndex, + description: resubmitMessage || milestone.description, + beneficiary: wallet.address || "", + }); break; } - if (txHash) { + setTxHash(txHash); + setTxPhase("success"); const successMessages: Record< string, { title: string; description: string } @@ -322,12 +319,80 @@ export function MilestoneActions({ setDialogOpen(false); onSuccess(); } - } catch (error: any) { + } catch (error: unknown) { + const guidance = getTransactionErrorGuidance(error); + const pendingHash = + error instanceof PendingTransactionError ? error.txHash : null; + if (pendingHash) setTxHash(pendingHash); + setTxPhase(pendingHash ? "pending" : "failed"); + setTxError(guidance); + setRetryAllowed(!pendingHash && guidance.retryable); toast({ - title: "Transaction failed", - description: error.message || "Failed to submit transaction", + title: guidance.title, + description: guidance.message, variant: "destructive", }); + } finally { + contractService.setTransactionProgressListener(undefined); + setIsLoading(false); + } + }; + + const checkPendingTransaction = async () => { + if (!txHash) return; + + setIsLoading(true); + try { + const status = await contractService.getTransactionStatus(txHash); + if (status === "success") { + setTxPhase("success"); + setTxError(null); + setRetryAllowed(false); + toast({ + title: "Transaction confirmed", + description: "The pending transaction succeeded on-chain.", + }); + window.dispatchEvent( + new CustomEvent("escrowUpdated", { + detail: { + escrowId: Number(escrowId), + milestoneIndex, + action: actionType, + }, + }), + ); + setDialogOpen(false); + onSuccess(); + return; + } + + if (status === "failed") { + setTxPhase("failed"); + setRetryAllowed(true); + setTxError({ + title: "Transaction failed on-chain", + message: + "The previous transaction is confirmed failed, so retrying will not double-submit it.", + action: "Review the inputs, then retry when ready.", + retryable: true, + }); + return; + } + + setTxPhase("pending"); + setRetryAllowed(false); + setTxError({ + title: + status === "not_found" + ? "Transaction not indexed yet" + : "Transaction still pending", + message: "No final on-chain result is available yet.", + action: "Check status again before retrying.", + retryable: false, + }); + } catch (error: unknown) { + setTxError(getTransactionErrorGuidance(error)); + setRetryAllowed(false); } finally { setIsLoading(false); } @@ -391,6 +456,15 @@ export function MilestoneActions({ resolve: CheckCircle2, }[actionType || "submit"] || Send; + const transactionStatusText: Record = { + idle: "Ready", + building: "Building transaction", + signing: "Waiting for wallet signature", + pending: "Pending on-chain confirmation", + success: "Confirmed", + failed: "Failed", + }; + return ( <>
@@ -656,6 +730,34 @@ export function MilestoneActions({
)} + {txPhase !== "idle" && ( +
+
+ {transactionStatusText[txPhase]} +
+ {txHash && ( +
Tx: {txHash}
+ )} + {txError && ( +
+
{txError.message}
+
{txError.action}
+
+ )} + {txPhase === "pending" && txHash && ( + + )} +
+ )} + - diff --git a/src/components/panel-error-boundary.tsx b/src/components/panel-error-boundary.tsx new file mode 100644 index 0000000..0eea83e --- /dev/null +++ b/src/components/panel-error-boundary.tsx @@ -0,0 +1,53 @@ +import { Component, type ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface PanelErrorBoundaryProps { + children: ReactNode; +} + +interface PanelErrorBoundaryState { + hasError: boolean; +} + +export class PanelErrorBoundary extends Component< + PanelErrorBoundaryProps, + PanelErrorBoundaryState +> { + state: PanelErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): PanelErrorBoundaryState { + return { hasError: true }; + } + + private reset = () => { + this.setState({ hasError: false }); + }; + + render() { + if (!this.state.hasError) return this.props.children; + + return ( +
+
+ +
+
+

This panel could not be rendered

+

+ The rest of SecureFlow is still available. Retry this panel, or + navigate elsewhere if the error persists. +

+
+ +
+
+
+ ); + } +} diff --git a/src/lib/web3/contract-service.ts b/src/lib/web3/contract-service.ts index 5659f72..e7f0071 100644 --- a/src/lib/web3/contract-service.ts +++ b/src/lib/web3/contract-service.ts @@ -17,6 +17,11 @@ import { import { Client as SecureFlowClient } from "@/contracts/generated/src/index"; import { getCurrentNetwork, CONTRACTS } from "./stellar-config"; import { signTransaction, signAuthEntries } from "./wallet-signer"; +import { + PendingTransactionError, + type TransactionPhase, + type TransactionProgressListener, +} from "./transaction-feedback"; import useWalletStore from "@/store/wallet.store"; export interface EscrowData { @@ -56,6 +61,40 @@ export class ContractService { private network: ReturnType; private client: SecureFlowClient; private rpcServer: rpc.Server; + private transactionProgressListener?: TransactionProgressListener; + + setTransactionProgressListener(listener?: TransactionProgressListener) { + this.transactionProgressListener = listener; + } + + private reportTransactionProgress(phase: TransactionPhase, txHash?: string) { + this.transactionProgressListener?.({ phase, txHash }); + } + + private async sendSignedTransaction( + signedTransaction: ReturnType, + ) { + const localHash = signedTransaction.hash().toString("hex"); + + let sendResponse; + try { + sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + } catch { + this.reportTransactionProgress("pending", localHash); + throw new PendingTransactionError(localHash); + } + + if (sendResponse.status === "TRY_AGAIN_LATER") { + throw new Error("Transaction should be retried later"); + } + + const txHash = sendResponse.hash || localHash; + if (sendResponse.status !== "ERROR") { + this.reportTransactionProgress("pending", txHash); + } + + return { sendResponse, txHash }; + } constructor(contractId?: string) { this.contractId = contractId || CONTRACTS.SECUREFLOW_ESCROW; @@ -1045,6 +1084,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -2175,6 +2215,8 @@ export class ContractService { .setTimeout(30) .build(); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: walletAddress, @@ -2187,6 +2229,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); const txHash = sendResponse.hash || ""; @@ -2282,6 +2325,7 @@ export class ContractService { } // No auth entries, sign normally + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -2294,6 +2338,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); const txHash = sendResponse.hash || ""; @@ -2417,6 +2462,7 @@ export class ContractService { throw new Error("Beneficiary address is required"); } + this.reportTransactionProgress("building"); try { // Build transaction manually with beneficiary as source account // This ensures the simulation detects auth requirements @@ -2498,6 +2544,7 @@ export class ContractService { .setTimeout(30) .build(); + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: beneficiary, @@ -2509,6 +2556,7 @@ export class ContractService { throw new Error("No operations found in prepared transaction"); } } else { + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: beneficiary, @@ -2520,18 +2568,18 @@ export class ContractService { this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); + const { sendResponse, txHash } = + await this.sendSignedTransaction(signedTransaction); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING" && sendResponse.hash) { - return await this.waitForConfirmation(sendResponse.hash); + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + return txHash; } catch (error: any) { throw error; } @@ -2549,6 +2597,7 @@ export class ContractService { throw new Error("Beneficiary address is required"); } + this.reportTransactionProgress("building"); try { // Build transaction manually with beneficiary as source account // This ensures the simulation detects auth requirements @@ -2631,6 +2680,7 @@ export class ContractService { .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: params.beneficiary, @@ -2642,6 +2692,7 @@ export class ContractService { throw new Error("No operations found in prepared transaction"); } } else { + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: params.beneficiary, @@ -2653,18 +2704,18 @@ export class ContractService { this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); + const { sendResponse, txHash } = + await this.sendSignedTransaction(signedTransaction); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING" && sendResponse.hash) { - return await this.waitForConfirmation(sendResponse.hash); + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + return txHash; } catch (error: any) { throw error; } @@ -2683,6 +2734,7 @@ export class ContractService { throw new Error("Beneficiary address is required"); } + this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -2760,6 +2812,7 @@ export class ContractService { .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: params.beneficiary, @@ -2771,6 +2824,7 @@ export class ContractService { throw new Error("No operations found in prepared transaction"); } } else { + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: params.beneficiary, @@ -2782,18 +2836,18 @@ export class ContractService { this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); + const { sendResponse, txHash } = + await this.sendSignedTransaction(signedTransaction); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING" && sendResponse.hash) { - return await this.waitForConfirmation(sendResponse.hash); + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + return txHash; } catch (error: any) { throw error; } @@ -2807,6 +2861,7 @@ export class ContractService { if (!params.depositor) { throw new Error("Depositor address is required"); } + this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -2874,6 +2929,7 @@ export class ContractService { .setTimeout(30) .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: params.depositor, @@ -2885,6 +2941,7 @@ export class ContractService { throw new Error("No operations found in prepared transaction"); } } else { + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: params.depositor, @@ -2894,15 +2951,18 @@ export class ContractService { signedTxXdr, this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); + const { sendResponse, txHash } = + await this.sendSignedTransaction(signedTransaction); + if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING" && sendResponse.hash) { - return await this.waitForConfirmation(sendResponse.hash); + + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + + return txHash; } catch (error: any) { throw error; } @@ -2917,6 +2977,7 @@ export class ContractService { if (!params.depositor) { throw new Error("Depositor address is required"); } + this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -2985,6 +3046,7 @@ export class ContractService { .setTimeout(30) .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: params.depositor, @@ -2996,6 +3058,7 @@ export class ContractService { throw new Error("No operations found in prepared transaction"); } } else { + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: params.depositor, @@ -3005,15 +3068,18 @@ export class ContractService { signedTxXdr, this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); + const { sendResponse, txHash } = + await this.sendSignedTransaction(signedTransaction); + if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING" && sendResponse.hash) { - return await this.waitForConfirmation(sendResponse.hash); + + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + + return txHash; } catch (error: any) { throw error; } @@ -3028,6 +3094,7 @@ export class ContractService { if (!params.disputer) { throw new Error("Disputer address is required"); } + this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -3096,6 +3163,7 @@ export class ContractService { .setTimeout(30) .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: params.disputer, @@ -3107,6 +3175,7 @@ export class ContractService { throw new Error("No operations found in prepared transaction"); } } else { + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: params.disputer, @@ -3116,15 +3185,18 @@ export class ContractService { signedTxXdr, this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); + const { sendResponse, txHash } = + await this.sendSignedTransaction(signedTransaction); + if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING" && sendResponse.hash) { - return await this.waitForConfirmation(sendResponse.hash); + + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + + return txHash; } catch (error: any) { throw error; } @@ -3391,6 +3463,7 @@ export class ContractService { .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: depositorAddress, @@ -3403,6 +3476,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3418,6 +3492,7 @@ export class ContractService { } // No auth entries, sign normally + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: depositorAddress, @@ -3430,6 +3505,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3635,6 +3711,7 @@ export class ContractService { .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: walletAddress, @@ -3647,6 +3724,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3662,6 +3740,7 @@ export class ContractService { } // No auth entries, sign normally + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -3674,6 +3753,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3764,6 +3844,7 @@ export class ContractService { .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: walletAddress, @@ -3776,6 +3857,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3791,6 +3873,7 @@ export class ContractService { } // No auth entries, sign normally + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -3803,6 +3886,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3831,6 +3915,8 @@ export class ContractService { fee_bp: feeBP, }); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: assembledTx.toXDR(), address: walletAddress, @@ -3843,6 +3929,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3871,6 +3958,8 @@ export class ContractService { fee_collector: feeCollector, }); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: assembledTx.toXDR(), address: walletAddress, @@ -3883,6 +3972,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -4022,6 +4112,8 @@ export class ContractService { } } + this.reportTransactionProgress("signing"); + const signedXdr = await signTransaction({ unsignedTransaction: (finalPrepared as any).toXDR(), address: ownerAddress, @@ -4032,6 +4124,7 @@ export class ContractService { this.network.networkPassphrase, ); const sendResponse = await this.rpcServer.sendTransaction(signedTx); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { let errMsg = "Transaction failed"; @@ -4084,6 +4177,7 @@ export class ContractService { .build(); const prepared = await this.rpcServer.prepareTransaction(tx); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -4096,6 +4190,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); } @@ -4261,6 +4356,8 @@ export class ContractService { .setTimeout(30) .build(); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: signerAddress, @@ -4273,6 +4370,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -4290,6 +4388,7 @@ export class ContractService { // No auth entries, sign normally // Use sourceAddress if provided, otherwise use walletAddress const signerAddress = sourceAddress || walletAddress; + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: signerAddress, @@ -4302,6 +4401,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -4316,51 +4416,64 @@ export class ContractService { /** * Wait for transaction confirmation + */ + /** + * Preserve an ambiguous transaction hash instead of inviting a blind retry. + */ + public async getTransactionStatus( + hash: string, + ): Promise<"success" | "failed" | "pending" | "not_found"> { + const txStatus = await this.rpcServer.getTransaction(hash); + switch (txStatus.status as string) { + case "SUCCESS": + return "success"; + case "FAILED": + return "failed"; + case "PENDING": + return "pending"; + case "NOT_FOUND": + default: + return "not_found"; + } + } + + /** + * Preserve an ambiguous transaction hash instead of inviting a blind retry. */ private async waitForConfirmation(hash: string): Promise { let attempts = 0; - const maxAttempts = 30; // 30 seconds + const maxAttempts = 30; while (attempts < maxAttempts) { await new Promise((resolve) => setTimeout(resolve, 1000)); - try { const txStatus = await this.rpcServer.getTransaction(hash); const status = txStatus.status as string; - - if (status === "SUCCESS") { - return hash; - } - - if (status === "FAILED") { - throw new Error("Transaction failed"); - } - - // If status is NOT_FOUND, transaction might still be processing - // Continue waiting - if (status === "NOT_FOUND") { - attempts++; - continue; - } - - // If status is PENDING, continue waiting - if (status === "PENDING") { - attempts++; - continue; - } - + if (status === "SUCCESS") return hash; + if (status === "FAILED") throw new Error("Transaction failed on-chain"); attempts++; } catch (error) { - // If error is about transaction not found, continue waiting - if (error instanceof Error && error.message.includes("not found")) { + const message = + error instanceof Error + ? error.message.toLowerCase() + : String(error).toLowerCase(); + if ( + message.includes("not found") || + message.includes("timeout") || + message.includes("network") || + message.includes("fetch") || + message.includes("temporarily") || + message.includes("503") || + message.includes("504") + ) { attempts++; continue; } - attempts++; + throw error; } } - throw new Error("Transaction still pending after waiting"); + throw new PendingTransactionError(hash); } } diff --git a/src/lib/web3/transaction-feedback.ts b/src/lib/web3/transaction-feedback.ts new file mode 100644 index 0000000..e4b9384 --- /dev/null +++ b/src/lib/web3/transaction-feedback.ts @@ -0,0 +1,163 @@ +export type TransactionPhase = + | "idle" + | "building" + | "signing" + | "pending" + | "success" + | "failed"; + +export interface TransactionProgress { + phase: TransactionPhase; + txHash?: string; +} + +export type TransactionProgressListener = ( + progress: TransactionProgress, +) => void; + +export interface TransactionErrorGuidance { + code?: number; + title: string; + message: string; + action: string; + retryable: boolean; +} + +type ErrorGuidanceEntry = Omit; + +const SECURE_FLOW_ERROR_GUIDANCE: Record = { + 1000: { title: "Already initialized", message: "This contract has already been initialized.", action: "Refresh the page and use the existing configuration.", retryable: false }, + 1001: { title: "Fee too high", message: "The requested platform fee exceeds the contract limit.", action: "Use a lower fee and submit again.", retryable: false }, + 1002: { title: "Not authorized", message: "Only the contract owner can perform this action.", action: "Connect the owner wallet or choose an action available to this account.", retryable: false }, + 1003: { title: "Contract not initialized", message: "The contract is not initialized yet.", action: "Initialize the contract before continuing.", retryable: false }, + 1100: { title: "Escrow not found", message: "The requested escrow does not exist.", action: "Refresh the escrow list and verify the escrow ID.", retryable: false }, + 1101: { title: "Escrow not active", message: "This escrow is not active for the requested action.", action: "Refresh its status before continuing.", retryable: false }, + 1102: { title: "Invalid escrow status", message: "The escrow is in a state that does not allow this action.", action: "Refresh the escrow and choose an action valid for its current state.", retryable: false }, + 1103: { title: "Work already started", message: "Work has already started for this escrow.", action: "Refresh the escrow instead of starting it again.", retryable: false }, + 1104: { title: "Work not started", message: "Work must be started before this action is available.", action: "Start work first, then retry the intended action.", retryable: false }, + 1200: { title: "Job creation paused", message: "New job creation is currently paused.", action: "Wait for an administrator to resume job creation.", retryable: false }, + 1201: { title: "Invalid duration", message: "The requested escrow duration is outside the allowed range.", action: "Choose a valid duration and submit again.", retryable: false }, + 1202: { title: "Milestone mismatch", message: "The milestone count does not match the submitted milestone data.", action: "Review the milestone list and submit it again.", retryable: false }, + 1203: { title: "Too many milestones", message: "This escrow contains more milestones than the contract allows.", action: "Reduce the milestone count.", retryable: false }, + 1204: { title: "Too many arbiters", message: "This escrow contains more arbiters than the contract allows.", action: "Reduce the arbiter count.", retryable: false }, + 1205: { title: "Invalid confirmations", message: "The required confirmation count is invalid for the selected arbiters.", action: "Choose a valid confirmation threshold.", retryable: false }, + 1206: { title: "Token not whitelisted", message: "The selected token is not approved for escrow.", action: "Choose a whitelisted token or ask an administrator to whitelist it.", retryable: false }, + 1300: { title: "Job is not open", message: "This escrow is not an open marketplace job.", action: "Refresh the listing and choose an open job.", retryable: false }, + 1301: { title: "Job closed", message: "This job no longer accepts applications.", action: "Refresh the marketplace and choose another open job.", retryable: false }, + 1302: { title: "Cannot apply to own job", message: "The job creator cannot apply as its freelancer.", action: "Use a different wallet or choose another job.", retryable: false }, + 1303: { title: "Application limit reached", message: "This job has reached its application limit.", action: "Choose another open job.", retryable: false }, + 1304: { title: "Depositor only", message: "Only the escrow depositor can perform this action.", action: "Connect the depositor wallet.", retryable: false }, + 1305: { title: "Freelancer did not apply", message: "The selected freelancer has not applied to this job.", action: "Select an existing applicant.", retryable: false }, + 1306: { title: "Already applied", message: "This wallet has already applied to the job.", action: "Refresh the application state instead of submitting again.", retryable: false }, + 1400: { title: "Invalid milestone", message: "The requested milestone does not exist.", action: "Refresh the escrow and select a valid milestone.", retryable: false }, + 1401: { title: "Milestone already submitted", message: "This milestone has already been submitted.", action: "Refresh the milestone state instead of submitting it again.", retryable: false }, + 1402: { title: "Milestone not submitted", message: "This milestone has not been submitted for review.", action: "Submit the milestone before approving, rejecting, or disputing it.", retryable: false }, + 1403: { title: "Milestone already processed", message: "This milestone has already been approved, rejected, or otherwise processed.", action: "Refresh the milestone state before taking another action.", retryable: false }, + 1500: { title: "Nothing to refund", message: "No refundable balance is available.", action: "Refresh the escrow balance.", retryable: false }, + 1501: { title: "Deadline not passed", message: "The escrow deadline has not passed yet.", action: "Wait until the deadline before requesting this refund.", retryable: false }, + 1502: { title: "Emergency period not reached", message: "The emergency refund period has not elapsed.", action: "Wait until the emergency period is reached.", retryable: false }, + 1503: { title: "Cannot refund", message: "The escrow cannot be refunded in its current state.", action: "Refresh the escrow and review the available resolution actions.", retryable: false }, + 1504: { title: "Invalid extension", message: "The requested deadline extension is invalid.", action: "Choose a valid extension period.", retryable: false }, + 1505: { title: "Cannot extend", message: "This escrow cannot be extended in its current state.", action: "Refresh the escrow and review its deadline state.", retryable: false }, + 1600: { title: "Beneficiary only", message: "Only the escrow beneficiary can perform this action.", action: "Connect the beneficiary wallet.", retryable: false }, + 1601: { title: "Not authorized", message: "This wallet is not authorized to perform the requested action.", action: "Connect the authorized wallet or choose an allowed action.", retryable: false }, + 1700: { title: "Invalid amount", message: "The submitted amount is invalid.", action: "Review the amount and submit again.", retryable: false }, + 1701: { title: "Invalid address", message: "One of the submitted Stellar addresses is invalid.", action: "Review the wallet or destination address.", retryable: false }, + 1702: { title: "Invalid parameter", message: "One or more transaction parameters are invalid.", action: "Review the form values and submit again.", retryable: false }, + 1703: { title: "Insufficient withdrawable balance", message: "The requested withdrawal exceeds the available balance.", action: "Lower the amount or refresh the withdrawable balance.", retryable: false }, + 1704: { title: "No overdue request", message: "There is no overdue-resolution request for this escrow.", action: "Refresh the escrow before resolving an overdue request.", retryable: false }, + 1705: { title: "No arbiters available", message: "No authorized arbiters are available for this action.", action: "Wait for an arbiter to become available or contact an administrator.", retryable: false }, + 1800: { title: "Escrow not completed", message: "Ratings are only available after escrow completion.", action: "Complete the escrow before submitting a rating.", retryable: false }, + 1801: { title: "Rating already submitted", message: "A rating has already been submitted for this escrow.", action: "Refresh the rating state instead of submitting again.", retryable: false }, + 1802: { title: "Invalid rating", message: "The rating value is outside the allowed range.", action: "Choose a valid rating value.", retryable: false }, + 1803: { title: "Depositor rating only", message: "Only the depositor can rate the freelancer.", action: "Connect the depositor wallet.", retryable: false }, + 1804: { title: "Beneficiary rating only", message: "Only the beneficiary can rate the client.", action: "Connect the beneficiary wallet.", retryable: false }, + 1805: { title: "Client rating already submitted", message: "The client has already been rated for this escrow.", action: "Refresh the rating state instead of submitting again.", retryable: false }, +}; + +export class PendingTransactionError extends Error { + constructor(public readonly txHash: string) { + super("Transaction is still pending on-chain."); + this.name = "PendingTransactionError"; + } +} + +export function extractSecureFlowErrorCode( + error: unknown, +): number | undefined { + const message = error instanceof Error ? error.message : String(error ?? ""); + for (const match of message.matchAll(/\b(1[0-8]\d{2})\b/g)) { + const code = Number(match[1]); + if (SECURE_FLOW_ERROR_GUIDANCE[code]) return code; + } + return undefined; +} + +export function getTransactionErrorGuidance( + error: unknown, +): TransactionErrorGuidance { + if (error instanceof PendingTransactionError) { + return { + title: "Transaction still pending", + message: "The transaction was broadcast but did not reach a final state before the confirmation timeout.", + action: "Check its on-chain status before retrying.", + retryable: false, + }; + } + + const code = extractSecureFlowErrorCode(error); + if (code) return { code, ...SECURE_FLOW_ERROR_GUIDANCE[code] }; + + const message = error instanceof Error ? error.message : String(error ?? ""); + const lower = message.toLowerCase(); + + if ( + lower.includes("user rejected") || + lower.includes("user declined") || + lower.includes("user cancelled") || + lower.includes("user canceled") + ) { + return { + title: "Signature cancelled", + message: "The wallet signature was cancelled before submission.", + action: "Retry when you are ready to approve the wallet request.", + retryable: true, + }; + } + + if ( + lower.includes("try_again_later") || + lower.includes("try again later") || + lower.includes("should be retried later") + ) { + return { + title: "RPC temporarily unavailable", + message: "The Stellar RPC asked the client to retry later and did not accept the transaction.", + action: "Retry the transaction.", + retryable: true, + }; + } + + if ( + lower.includes("timeout") || + lower.includes("network") || + lower.includes("fetch") || + lower.includes("connection") || + lower.includes("503") || + lower.includes("504") + ) { + return { + title: "Network error", + message: "The network request did not complete cleanly.", + action: "Reconnect if needed. Retry only when no pending transaction hash is shown.", + retryable: true, + }; + } + + return { + title: "Transaction failed", + message: message || "The transaction could not be completed.", + action: "Review the current escrow state before trying again.", + retryable: false, + }; +}