From f09739d5f717380e1cb171c7c4a07a61809e6c65 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:54:26 -0400 Subject: [PATCH 01/13] feat: add transaction status and error guidance --- src/lib/web3/transaction-feedback.ts | 163 +++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/lib/web3/transaction-feedback.ts diff --git a/src/lib/web3/transaction-feedback.ts b/src/lib/web3/transaction-feedback.ts new file mode 100644 index 0000000..7471dfe --- /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: false, + }; + } + + return { + title: "Transaction failed", + message: message || "The transaction could not be completed.", + action: "Review the current escrow state before trying again.", + retryable: false, + }; +} From 389c8dc1127429116d9a14288a35c04d2bce12b8 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:54:28 -0400 Subject: [PATCH 02/13] feat: isolate panel render failures --- src/components/panel-error-boundary.tsx | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/components/panel-error-boundary.tsx 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. +

+
+ +
+
+
+ ); + } +} From 74282576ae9bd9ee9846aaa561fc0e41d6cd052e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:55:09 -0400 Subject: [PATCH 03/13] fix: preserve transaction state through confirmation --- src/lib/web3/contract-service.ts | 126 ++++++++++++++++++++++++------- 1 file changed, 98 insertions(+), 28 deletions(-) diff --git a/src/lib/web3/contract-service.ts b/src/lib/web3/contract-service.ts index 5659f72..10714b0 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,15 @@ 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 }); + } constructor(contractId?: string) { this.contractId = contractId || CONTRACTS.SECUREFLOW_ESCROW; @@ -1045,6 +1059,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 +2190,8 @@ export class ContractService { .setTimeout(30) .build(); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: walletAddress, @@ -2187,6 +2204,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); const txHash = sendResponse.hash || ""; @@ -2282,6 +2300,7 @@ export class ContractService { } // No auth entries, sign normally + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -2294,6 +2313,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); const txHash = sendResponse.hash || ""; @@ -2417,6 +2437,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 @@ -2522,6 +2543,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"); @@ -2549,6 +2571,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 @@ -2655,6 +2678,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"); @@ -2683,6 +2707,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"); @@ -2784,6 +2809,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"); @@ -2807,6 +2833,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"); @@ -2896,6 +2923,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"); } @@ -2917,6 +2945,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"); @@ -3007,6 +3036,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"); } @@ -3028,6 +3058,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"); @@ -3118,6 +3149,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"); } @@ -3391,6 +3423,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 +3436,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 +3452,7 @@ export class ContractService { } // No auth entries, sign normally + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: depositorAddress, @@ -3430,6 +3465,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 +3671,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 +3684,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 +3700,7 @@ export class ContractService { } // No auth entries, sign normally + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -3674,6 +3713,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 +3804,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 +3817,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 +3833,7 @@ export class ContractService { } // No auth entries, sign normally + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -3803,6 +3846,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 +3875,8 @@ export class ContractService { fee_bp: feeBP, }); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: assembledTx.toXDR(), address: walletAddress, @@ -3843,6 +3889,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 +3918,8 @@ export class ContractService { fee_collector: feeCollector, }); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: assembledTx.toXDR(), address: walletAddress, @@ -3883,6 +3932,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 +4072,8 @@ export class ContractService { } } + this.reportTransactionProgress("signing"); + const signedXdr = await signTransaction({ unsignedTransaction: (finalPrepared as any).toXDR(), address: ownerAddress, @@ -4032,6 +4084,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 +4137,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 +4150,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 +4316,8 @@ export class ContractService { .setTimeout(30) .build(); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: signerAddress, @@ -4273,6 +4330,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 +4348,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 +4361,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"); @@ -4317,50 +4377,60 @@ export class ContractService { /** * Wait for transaction confirmation */ + 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); } } From a2673dd934f20a5355b3f9be0038b7b6b5eac824 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:55:40 -0400 Subject: [PATCH 04/13] fix: preserve transaction state through confirmation --- src/lib/web3/contract-service.ts | 80 ++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/lib/web3/contract-service.ts b/src/lib/web3/contract-service.ts index 10714b0..87dff81 100644 --- a/src/lib/web3/contract-service.ts +++ b/src/lib/web3/contract-service.ts @@ -22,6 +22,11 @@ import { type TransactionPhase, type TransactionProgressListener, } from "./transaction-feedback"; +import { + PendingTransactionError, + type TransactionPhase, + type TransactionProgressListener, +} from "./transaction-feedback"; import useWalletStore from "@/store/wallet.store"; export interface EscrowData { @@ -67,6 +72,15 @@ export class ContractService { this.transactionProgressListener = listener; } + private reportTransactionProgress(phase: TransactionPhase, txHash?: string) { + this.transactionProgressListener?.({ phase, txHash }); + } + private transactionProgressListener?: TransactionProgressListener; + + setTransactionProgressListener(listener?: TransactionProgressListener) { + this.transactionProgressListener = listener; + } + private reportTransactionProgress(phase: TransactionPhase, txHash?: string) { this.transactionProgressListener?.({ phase, txHash }); } @@ -1059,6 +1073,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -2192,6 +2207,8 @@ export class ContractService { this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: walletAddress, @@ -2204,6 +2221,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); const txHash = sendResponse.hash || ""; @@ -2301,6 +2319,7 @@ export class ContractService { // No auth entries, sign normally this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -2313,6 +2332,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); const txHash = sendResponse.hash || ""; @@ -2437,6 +2457,7 @@ export class ContractService { throw new Error("Beneficiary address is required"); } + this.reportTransactionProgress("building"); this.reportTransactionProgress("building"); try { // Build transaction manually with beneficiary as source account @@ -2543,6 +2564,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -2571,6 +2593,7 @@ export class ContractService { throw new Error("Beneficiary address is required"); } + this.reportTransactionProgress("building"); this.reportTransactionProgress("building"); try { // Build transaction manually with beneficiary as source account @@ -2678,6 +2701,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -2707,6 +2731,7 @@ export class ContractService { throw new Error("Beneficiary address is required"); } + this.reportTransactionProgress("building"); this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = @@ -2809,6 +2834,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -2834,6 +2860,7 @@ export class ContractService { throw new Error("Depositor address is required"); } this.reportTransactionProgress("building"); + this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -2923,6 +2950,7 @@ export class ContractService { ); const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -2946,6 +2974,7 @@ export class ContractService { throw new Error("Depositor address is required"); } this.reportTransactionProgress("building"); + this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -3036,6 +3065,7 @@ export class ContractService { ); const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3059,6 +3089,7 @@ export class ContractService { throw new Error("Disputer address is required"); } this.reportTransactionProgress("building"); + this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -3149,6 +3180,7 @@ export class ContractService { ); const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3424,6 +3456,7 @@ export class ContractService { const newPrepared = await this.rpcServer.prepareTransaction(newTx); this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: depositorAddress, @@ -3436,6 +3469,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3453,6 +3487,7 @@ export class ContractService { // No auth entries, sign normally this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: depositorAddress, @@ -3465,6 +3500,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3672,6 +3708,7 @@ export class ContractService { const newPrepared = await this.rpcServer.prepareTransaction(newTx); this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: walletAddress, @@ -3684,6 +3721,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3701,6 +3739,7 @@ export class ContractService { // No auth entries, sign normally this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -3713,6 +3752,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3805,6 +3845,7 @@ export class ContractService { const newPrepared = await this.rpcServer.prepareTransaction(newTx); this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: walletAddress, @@ -3817,6 +3858,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3834,6 +3876,7 @@ export class ContractService { // No auth entries, sign normally this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -3846,6 +3889,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3877,6 +3921,8 @@ export class ContractService { this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: assembledTx.toXDR(), address: walletAddress, @@ -3889,6 +3935,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3920,6 +3967,8 @@ export class ContractService { this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: assembledTx.toXDR(), address: walletAddress, @@ -3932,6 +3981,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -4074,6 +4124,8 @@ export class ContractService { this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); + const signedXdr = await signTransaction({ unsignedTransaction: (finalPrepared as any).toXDR(), address: ownerAddress, @@ -4085,6 +4137,7 @@ export class ContractService { ); const sendResponse = await this.rpcServer.sendTransaction(signedTx); this.reportTransactionProgress("pending", sendResponse.hash); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { let errMsg = "Transaction failed"; @@ -4138,6 +4191,7 @@ export class ContractService { const prepared = await this.rpcServer.prepareTransaction(tx); this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -4150,6 +4204,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -4318,6 +4373,8 @@ export class ContractService { this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); + const signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: signerAddress, @@ -4330,6 +4387,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); + this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -4349,6 +4407,7 @@ export class ContractService { // Use sourceAddress if provided, otherwise use walletAddress const signerAddress = sourceAddress || walletAddress; this.reportTransactionProgress("signing"); + this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: signerAddress, @@ -4362,6 +4421,7 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); this.reportTransactionProgress("pending", sendResponse.hash); + this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -4394,6 +4454,26 @@ export class ContractService { } } + /** + * 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. */ From 2869cf4753538cf20e96ba4789d84d50502058cd Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:56:07 -0400 Subject: [PATCH 05/13] feat: surface milestone transaction lifecycle --- src/components/milestone-actions.tsx | 215 ++++++++++++++++++++------- 1 file changed, 164 insertions(+), 51 deletions(-) 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 && ( + + )} +
+ )} + - From 9d7cdbdc87a54036b82a6c972e651cae3b29df6d Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:56:48 -0400 Subject: [PATCH 06/13] feat: isolate route render failures --- src/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 5e3a321..c5839d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,12 +14,15 @@ 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 = () => ( <>
- + + +
From 23013cf43d7fa617299db8ccb5a4789a9ca5b116 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 22:57:49 -0400 Subject: [PATCH 07/13] fix: deduplicate transaction progress instrumentation --- src/lib/web3/contract-service.ts | 79 +------------------------------- 1 file changed, 1 insertion(+), 78 deletions(-) diff --git a/src/lib/web3/contract-service.ts b/src/lib/web3/contract-service.ts index 87dff81..a4e6f41 100644 --- a/src/lib/web3/contract-service.ts +++ b/src/lib/web3/contract-service.ts @@ -22,11 +22,6 @@ import { type TransactionPhase, type TransactionProgressListener, } from "./transaction-feedback"; -import { - PendingTransactionError, - type TransactionPhase, - type TransactionProgressListener, -} from "./transaction-feedback"; import useWalletStore from "@/store/wallet.store"; export interface EscrowData { @@ -72,15 +67,6 @@ export class ContractService { this.transactionProgressListener = listener; } - private reportTransactionProgress(phase: TransactionPhase, txHash?: string) { - this.transactionProgressListener?.({ phase, txHash }); - } - private transactionProgressListener?: TransactionProgressListener; - - setTransactionProgressListener(listener?: TransactionProgressListener) { - this.transactionProgressListener = listener; - } - private reportTransactionProgress(phase: TransactionPhase, txHash?: string) { this.transactionProgressListener?.({ phase, txHash }); } @@ -1073,7 +1059,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -2207,8 +2192,6 @@ export class ContractService { this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); - const signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: walletAddress, @@ -2221,7 +2204,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); const txHash = sendResponse.hash || ""; @@ -2319,7 +2301,6 @@ export class ContractService { // No auth entries, sign normally this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -2332,7 +2313,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); const txHash = sendResponse.hash || ""; @@ -2457,7 +2437,6 @@ export class ContractService { throw new Error("Beneficiary address is required"); } - this.reportTransactionProgress("building"); this.reportTransactionProgress("building"); try { // Build transaction manually with beneficiary as source account @@ -2564,7 +2543,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -2593,7 +2571,6 @@ export class ContractService { throw new Error("Beneficiary address is required"); } - this.reportTransactionProgress("building"); this.reportTransactionProgress("building"); try { // Build transaction manually with beneficiary as source account @@ -2701,7 +2678,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -2731,7 +2707,6 @@ export class ContractService { throw new Error("Beneficiary address is required"); } - this.reportTransactionProgress("building"); this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = @@ -2834,7 +2809,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -2860,7 +2834,6 @@ export class ContractService { throw new Error("Depositor address is required"); } this.reportTransactionProgress("building"); - this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -2950,7 +2923,6 @@ export class ContractService { ); const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -2974,7 +2946,6 @@ export class ContractService { throw new Error("Depositor address is required"); } this.reportTransactionProgress("building"); - this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -3065,7 +3036,6 @@ export class ContractService { ); const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3089,7 +3059,6 @@ export class ContractService { throw new Error("Disputer address is required"); } this.reportTransactionProgress("building"); - this.reportTransactionProgress("building"); try { const { Contract, nativeToScVal, TransactionBuilder, Operation, xdr } = await import("@stellar/stellar-sdk"); @@ -3180,7 +3149,6 @@ export class ContractService { ); const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -3456,7 +3424,6 @@ export class ContractService { const newPrepared = await this.rpcServer.prepareTransaction(newTx); this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: depositorAddress, @@ -3469,7 +3436,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3487,7 +3453,6 @@ export class ContractService { // No auth entries, sign normally this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: depositorAddress, @@ -3500,7 +3465,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3708,7 +3672,6 @@ export class ContractService { const newPrepared = await this.rpcServer.prepareTransaction(newTx); this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: walletAddress, @@ -3721,7 +3684,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3739,7 +3701,6 @@ export class ContractService { // No auth entries, sign normally this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -3752,7 +3713,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3845,7 +3805,6 @@ export class ContractService { const newPrepared = await this.rpcServer.prepareTransaction(newTx); this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: walletAddress, @@ -3858,7 +3817,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3876,7 +3834,6 @@ export class ContractService { // No auth entries, sign normally this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -3889,7 +3846,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3921,8 +3877,6 @@ export class ContractService { this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); - const signedTxXdr = await signTransaction({ unsignedTransaction: assembledTx.toXDR(), address: walletAddress, @@ -3935,7 +3889,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -3967,8 +3920,6 @@ export class ContractService { this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); - const signedTxXdr = await signTransaction({ unsignedTransaction: assembledTx.toXDR(), address: walletAddress, @@ -3981,7 +3932,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -4124,8 +4074,6 @@ export class ContractService { this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); - const signedXdr = await signTransaction({ unsignedTransaction: (finalPrepared as any).toXDR(), address: ownerAddress, @@ -4137,7 +4085,6 @@ export class ContractService { ); const sendResponse = await this.rpcServer.sendTransaction(signedTx); this.reportTransactionProgress("pending", sendResponse.hash); - this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { let errMsg = "Transaction failed"; @@ -4191,7 +4138,6 @@ export class ContractService { const prepared = await this.rpcServer.prepareTransaction(tx); this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: walletAddress, @@ -4204,7 +4150,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -4373,8 +4318,6 @@ export class ContractService { this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); - const signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: signerAddress, @@ -4387,7 +4330,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { @@ -4407,7 +4349,6 @@ export class ContractService { // Use sourceAddress if provided, otherwise use walletAddress const signerAddress = sourceAddress || walletAddress; this.reportTransactionProgress("signing"); - this.reportTransactionProgress("signing"); const signedTxXdr = await signTransaction({ unsignedTransaction: prepared.toXDR(), address: signerAddress, @@ -4421,7 +4362,6 @@ export class ContractService { const sendResponse = await this.rpcServer.sendTransaction(signedTransaction); this.reportTransactionProgress("pending", sendResponse.hash); - this.reportTransactionProgress("pending", sendResponse.hash); if (sendResponse.status === "ERROR") { throw new Error("Transaction failed"); @@ -4437,24 +4377,7 @@ export class ContractService { /** * Wait for transaction confirmation */ - 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. */ public async getTransactionStatus( From 8bba8c7e9ffb233ee21ba63f7cd1a3ac1b19660a Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 01:30:25 -0400 Subject: [PATCH 08/13] fix: report signing phase for milestone actions --- src/lib/web3/contract-service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/web3/contract-service.ts b/src/lib/web3/contract-service.ts index a4e6f41..dd495ab 100644 --- a/src/lib/web3/contract-service.ts +++ b/src/lib/web3/contract-service.ts @@ -2519,6 +2519,7 @@ export class ContractService { .setTimeout(30) .build(); + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: newTx.toXDR(), address: beneficiary, @@ -2530,6 +2531,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, @@ -2654,6 +2656,7 @@ export class ContractService { .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: params.beneficiary, @@ -2665,6 +2668,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, @@ -2785,6 +2789,7 @@ export class ContractService { .build(); const newPrepared = await this.rpcServer.prepareTransaction(newTx); + this.reportTransactionProgress("signing"); signedTxXdr = await signTransaction({ unsignedTransaction: newPrepared.toXDR(), address: params.beneficiary, @@ -2796,6 +2801,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, From f2a2fa2ae69f19d8a12f2848a3eb51787f297e16 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 02:17:41 -0400 Subject: [PATCH 09/13] fix(frontend): report signing for all milestone actions --- src/lib/web3/contract-service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/web3/contract-service.ts b/src/lib/web3/contract-service.ts index dd495ab..4b4acc3 100644 --- a/src/lib/web3/contract-service.ts +++ b/src/lib/web3/contract-service.ts @@ -2907,6 +2907,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, @@ -2918,6 +2919,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, @@ -3020,6 +3022,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, @@ -3031,6 +3034,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, @@ -3133,6 +3137,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, @@ -3144,6 +3149,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, From 73137f63acfc55d341e670ae8b1f6896cbc5caf9 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 02:19:26 -0400 Subject: [PATCH 10/13] fix(frontend): allow safe retry after transient network errors --- src/lib/web3/transaction-feedback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/web3/transaction-feedback.ts b/src/lib/web3/transaction-feedback.ts index 7471dfe..e4b9384 100644 --- a/src/lib/web3/transaction-feedback.ts +++ b/src/lib/web3/transaction-feedback.ts @@ -150,7 +150,7 @@ export function getTransactionErrorGuidance( 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: false, + retryable: true, }; } From 072e7abc83c4d3573bd5371e2437bd4e1f4eea58 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 02:38:38 -0400 Subject: [PATCH 11/13] fix(ui): reset panel boundary on navigation --- src/App.tsx | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c5839d7..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"; @@ -16,19 +16,23 @@ 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 ( From 072d58380b2cc858fb685b85f2ad75a4ce76f6cb Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 13:51:50 -0400 Subject: [PATCH 12/13] fix: preserve transaction identity across ambiguous broadcasts --- src/lib/web3/contract-service.ts | 100 ++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 36 deletions(-) diff --git a/src/lib/web3/contract-service.ts b/src/lib/web3/contract-service.ts index 4b4acc3..4599961 100644 --- a/src/lib/web3/contract-service.ts +++ b/src/lib/web3/contract-service.ts @@ -71,6 +71,31 @@ export class ContractService { 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; this.network = getCurrentNetwork(); @@ -2543,19 +2568,18 @@ export class ContractService { this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); + 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") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + return txHash; } catch (error: any) { throw error; } @@ -2680,19 +2704,18 @@ export class ContractService { this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); + 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") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + return txHash; } catch (error: any) { throw error; } @@ -2813,19 +2836,18 @@ export class ContractService { this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); + 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") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + return txHash; } catch (error: any) { throw error; } @@ -2929,16 +2951,18 @@ export class ContractService { signedTxXdr, this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); + 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") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + + return txHash; } catch (error: any) { throw error; } @@ -3044,16 +3068,18 @@ export class ContractService { signedTxXdr, this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); + 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") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + + return txHash; } catch (error: any) { throw error; } @@ -3159,16 +3185,18 @@ export class ContractService { signedTxXdr, this.network.networkPassphrase, ); - const sendResponse = - await this.rpcServer.sendTransaction(signedTransaction); - this.reportTransactionProgress("pending", sendResponse.hash); + 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") { + return await this.waitForConfirmation(txHash); } - return sendResponse.hash || ""; + + return txHash; } catch (error: any) { throw error; } From 9c967a74e10402b34b68d191fb2e7e450db2f52f Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 15:14:53 -0400 Subject: [PATCH 13/13] fix: wait for duplicate transaction finality --- src/lib/web3/contract-service.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/web3/contract-service.ts b/src/lib/web3/contract-service.ts index 4599961..e7f0071 100644 --- a/src/lib/web3/contract-service.ts +++ b/src/lib/web3/contract-service.ts @@ -2575,7 +2575,7 @@ export class ContractService { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING") { + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { return await this.waitForConfirmation(txHash); } @@ -2711,7 +2711,7 @@ export class ContractService { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING") { + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { return await this.waitForConfirmation(txHash); } @@ -2843,7 +2843,7 @@ export class ContractService { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING") { + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { return await this.waitForConfirmation(txHash); } @@ -2958,7 +2958,7 @@ export class ContractService { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING") { + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { return await this.waitForConfirmation(txHash); } @@ -3075,7 +3075,7 @@ export class ContractService { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING") { + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { return await this.waitForConfirmation(txHash); } @@ -3192,7 +3192,7 @@ export class ContractService { throw new Error("Transaction failed"); } - if (sendResponse.status === "PENDING") { + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { return await this.waitForConfirmation(txHash); }