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.
+
+
+
+ Retry panel
+
+
+
+
+ );
+ }
+}
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,
+ };
+}