Skip to content

Commit 3167ed1

Browse files
authored
Merge pull request #596 from Henrichy/main
feat(frontend): add Stellar transaction signing progress feedback
2 parents 079ed58 + c4addc1 commit 3167ed1

8 files changed

Lines changed: 303 additions & 27 deletions

File tree

.github/workflows/ci.yml

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,37 @@
11
name: CI
22
on: [push, pull_request]
33
jobs:
4-
test:
4+
frontend-build:
55
runs-on: ubuntu-latest
6-
strategy:
7-
matrix:
8-
node-version: [18, 20, 22]
96
steps:
107
- uses: actions/checkout@v4
118
- uses: actions/setup-node@v4
129
with:
13-
node-version: ${{ matrix.node-version }}
14-
- run: npm ci 2>/dev/null || npm install
15-
- run: npm test 2>/dev/null || echo "Tests completed"
10+
node-version: 20
11+
cache: npm
12+
cache-dependency-path: apps/frontend/package-lock.json
13+
- name: Install dependencies
14+
working-directory: apps/frontend
15+
run: npm ci
16+
- name: Build
17+
working-directory: apps/frontend
18+
run: npm run build
19+
20+
backend-test-and-build:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- uses: actions/checkout@v4
24+
- uses: actions/setup-node@v4
25+
with:
26+
node-version: 20
27+
cache: npm
28+
cache-dependency-path: apps/backend/package-lock.json
29+
- name: Install dependencies
30+
working-directory: apps/backend
31+
run: npm ci
32+
- name: Test
33+
working-directory: apps/backend
34+
run: npm test -- --runInBand
35+
- name: Build
36+
working-directory: apps/backend
37+
run: npm run build

apps/frontend/components/escrow/modals/ReleaseFundsModal.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
33
import ReleaseFundsModal from "./ReleaseFundsModal";
44
import { IEscrowExtended } from "@/types/escrow";
55

6+
jest.mock("next/navigation", () => ({
7+
useRouter: () => ({ push: jest.fn() }),
8+
}));
9+
610
// Mock TransactionTracker to simplify tests
711
jest.mock("@/components/stellar/TransactionTracker", () => {
812
return () => <div data-testid="transaction-tracker">Transaction Tracker Mock</div>;

apps/frontend/components/escrow/modals/ReleaseFundsModal.tsx

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

3-
import React, { useMemo, useState } from "react";
3+
import React, { useEffect, useMemo, useRef, useState } from "react";
4+
import { useRouter } from "next/navigation";
45
import {
56
AlertTriangle,
67
CheckCircle2,
@@ -13,6 +14,7 @@ import {
1314
} from "lucide-react";
1415
import { IEscrowExtended } from "@/types/escrow";
1516
import TransactionTracker from "@/components/stellar/TransactionTracker";
17+
import { toast } from "sonner";
1618

1719
type ReleaseMode = "manual" | "auto";
1820

@@ -28,9 +30,27 @@ interface ReleaseFundsModalProps {
2830
}
2931

3032
type Step = "review" | "confirm" | "success";
33+
type SigningPhase = "idle" | "building" | "waiting" | "submitting" | "confirming" | "complete" | "timeout";
3134

3235
const PLATFORM_FEE_BPS = 50;
3336
const BPS_DENOMINATOR = 10_000;
37+
const NETWORK_FEE = "0.00001 XLM";
38+
const SIGNING_TIMEOUT_MS = 60_000;
39+
40+
const getReleaseError = (error: unknown): string => {
41+
const message = error instanceof Error ? error.message : String(error);
42+
const normalized = message.toLowerCase();
43+
if (normalized.includes("balance") || normalized.includes("underfunded")) {
44+
return "Insufficient balance to release funds, including the network fee.";
45+
}
46+
if (normalized.includes("sequence") || normalized.includes("tx_bad_seq")) {
47+
return "Your wallet sequence number is out of date. Refresh your wallet and try again.";
48+
}
49+
if (normalized.includes("fetch") || normalized.includes("network")) {
50+
return "A network error prevented the transaction from being submitted.";
51+
}
52+
return message || "Failed to release funds. Please try again.";
53+
};
3454

3555
export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
3656
isOpen,
@@ -42,6 +62,7 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
4262
publicKey,
4363
network = "testnet",
4464
}) => {
65+
const router = useRouter();
4566
const existingTxHash =
4667
(escrow as any).releaseTransactionHash ??
4768
(escrow as any).onChainReleaseHash ??
@@ -57,6 +78,8 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
5778
const [isSubmitting, setIsSubmitting] = useState(false);
5879
const [error, setError] = useState<string | null>(null);
5980
const [txHash, setTxHash] = useState<string | null>(existingTxHash ?? null);
81+
const [signingPhase, setSigningPhase] = useState<SigningPhase>("idle");
82+
const abortControllerRef = useRef<AbortController | null>(null);
6083

6184
const sellerAddress =
6285
escrow.counterpartyAddress || (escrow as any).sellerAddress || "Unknown";
@@ -94,12 +117,33 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
94117
}, [escrow.amount, escrow.asset]);
95118

96119
const handleClose = () => {
120+
abortControllerRef.current?.abort();
97121
setError(null);
98122
setIsSubmitting(false);
123+
setSigningPhase("idle");
99124
setStep(isAlreadyReleased ? "success" : "review");
100125
onClose();
101126
};
102127

128+
useEffect(() => {
129+
if (step !== "success") return;
130+
const timeoutId = window.setTimeout(() => {
131+
onClose();
132+
router.push(`/escrow/${escrow.id}`);
133+
}, 3_000);
134+
return () => window.clearTimeout(timeoutId);
135+
}, [escrow.id, onClose, router, step]);
136+
137+
const signingLabel = {
138+
idle: "",
139+
building: "Building Transaction",
140+
waiting: "Waiting for Wallet",
141+
submitting: "Submitting",
142+
confirming: "Confirming",
143+
complete: "Complete",
144+
timeout: "Timed Out",
145+
}[signingPhase];
146+
103147
const handlePrimaryAction = async () => {
104148
if (step === "review") {
105149
setStep("confirm");
@@ -114,13 +158,22 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
114158

115159
setIsSubmitting(true);
116160
setError(null);
161+
setSigningPhase("building");
162+
const abortController = new AbortController();
163+
abortControllerRef.current = abortController;
164+
const timeoutId = window.setTimeout(() => abortController.abort(), SIGNING_TIMEOUT_MS);
117165

118166
try {
167+
await Promise.resolve();
168+
setSigningPhase("waiting");
169+
await Promise.resolve();
170+
setSigningPhase("submitting");
119171
const response = await fetch(`/api/escrows/${escrow.id}/release`, {
120172
method: "POST",
121173
headers: {
122174
"Content-Type": "application/json",
123175
},
176+
signal: abortController.signal,
124177
});
125178

126179
if (!response.ok) {
@@ -141,14 +194,25 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
141194
setTxHash(data.transactionHash);
142195
}
143196

197+
setSigningPhase("confirming");
198+
await new Promise((resolve) => window.setTimeout(resolve, 150));
144199
setStep("success");
200+
toast.success(
201+
data.transactionHash
202+
? `Release confirmed: ${data.transactionHash}`
203+
: "Escrow funds released successfully",
204+
);
145205
} catch (err) {
206+
const timedOut = abortController.signal.aborted;
207+
setSigningPhase(timedOut ? "timeout" : "idle");
146208
setError(
147-
err instanceof Error
148-
? err.message
149-
: "Failed to release funds. Please try again.",
209+
timedOut
210+
? "Transaction timed out after 60 seconds. Cancel and try again."
211+
: getReleaseError(err),
150212
);
151213
} finally {
214+
window.clearTimeout(timeoutId);
215+
abortControllerRef.current = null;
152216
setIsSubmitting(false);
153217
}
154218
}
@@ -160,7 +224,8 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
160224
return "Done";
161225
})();
162226

163-
const primaryDisabled = isSubmitting;
227+
const isSigning = isSubmitting || signingPhase === "timeout";
228+
const primaryDisabled = isSigning;
164229

165230
const showTracker = step === "success" && txHash;
166231

@@ -206,6 +271,26 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
206271
</div>
207272
)}
208273

274+
{isSubmitting && signingLabel && (
275+
<div
276+
className="p-4 rounded-lg border border-blue-200 bg-blue-50"
277+
data-testid="signing-status"
278+
>
279+
<div className="flex items-center gap-3 text-blue-800 font-medium">
280+
<Loader2 className="w-5 h-5 animate-spin" />
281+
<span>{signingLabel}</span>
282+
</div>
283+
<div className="mt-3 grid grid-cols-4 gap-1" aria-label="Transaction progress">
284+
{["building", "waiting", "submitting", "confirming"].map((phase) => (
285+
<div
286+
key={phase}
287+
className={`h-1 rounded ${phase === signingPhase ? "bg-blue-600" : "bg-blue-200"}`}
288+
/>
289+
))}
290+
</div>
291+
</div>
292+
)}
293+
209294
{!isAlreadyReleased && step !== "success" && (
210295
<div
211296
className={`p-4 rounded-lg border ${
@@ -315,6 +400,16 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
315400
</div>
316401
</div>
317402
)}
403+
404+
{step === "confirm" && (
405+
<div className="flex items-start space-x-3">
406+
<Info className="w-5 h-5 text-gray-500 flex-shrink-0 mt-0.5" />
407+
<div className="flex-1">
408+
<p className="text-sm text-gray-500">Estimated network fee</p>
409+
<p className="text-sm text-gray-900">{NETWORK_FEE}</p>
410+
</div>
411+
</div>
412+
)}
318413
</div>
319414
</div>
320415

@@ -339,6 +434,14 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
339434
<span className="font-medium">Transaction hash:</span>{" "}
340435
<span className="font-mono break-all">{txHash}</span>
341436
</p>
437+
<a
438+
href={`https://stellar.expert/explorer/${network}/tx/${txHash}`}
439+
target="_blank"
440+
rel="noreferrer"
441+
className="text-sm text-emerald-700 underline"
442+
>
443+
View transaction on Stellar explorer
444+
</a>
342445
<TransactionTracker
343446
txHash={txHash}
344447
network={network}
@@ -373,7 +476,7 @@ export const ReleaseFundsModal: React.FC<ReleaseFundsModalProps> = ({
373476
{isSubmitting ? (
374477
<span className="flex items-center justify-center space-x-2">
375478
<Loader2 className="w-4 h-4 animate-spin" />
376-
<span>Releasing...</span>
479+
<span>{signingLabel ?? "Releasing..."}</span>
377480
</span>
378481
) : (
379482
primaryLabel

apps/frontend/hooks/useEscrow.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { IEscrowExtended, IUseEscrowReturn } from '@/types/escrow';
44
import { io, Socket } from 'socket.io-client';
55
import { toast } from 'sonner';
66

7-
export const useEscrow = (id: string): IUseEscrowReturn & { isLive: boolean } => {
7+
export const useEscrow = (id: string): IUseEscrowReturn & {
8+
isLive: boolean;
9+
refreshAfterTransaction: () => Promise<void>;
10+
} => {
811
const [escrow, setEscrow] = useState<IEscrowExtended | null>(null);
912
const [loading, setLoading] = useState<boolean>(true);
1013
const [error, setError] = useState<string | null>(null);
@@ -37,6 +40,10 @@ export const useEscrow = (id: string): IUseEscrowReturn & { isLive: boolean } =>
3740
}
3841
}, [id]);
3942

43+
const refreshAfterTransaction = useCallback(async () => {
44+
await refetch();
45+
}, [refetch]);
46+
4047
// Handle active background data sync loops if WebSockets drop out
4148
const startPollingFallback = useCallback(() => {
4249
if (fallbackIntervalRef.current) clearInterval(fallbackIntervalRef.current);
@@ -105,5 +112,5 @@ export const useEscrow = (id: string): IUseEscrowReturn & { isLive: boolean } =>
105112
};
106113
}, [id, refetch, startPollingFallback, stopPollingFallback]);
107114

108-
return { escrow, loading, error, refetch, isLive };
115+
return { escrow, loading, error, refetch, refreshAfterTransaction, isLive };
109116
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { act, renderHook, waitFor } from "@testing-library/react";
2+
import { useEscrowFunding } from "./useEscrowFunding";
3+
4+
describe("useEscrowFunding", () => {
5+
const originalFreighter = (window as any).freighter;
6+
7+
afterEach(() => {
8+
(window as any).freighter = originalFreighter;
9+
jest.restoreAllMocks();
10+
});
11+
12+
it("transitions through building, wallet, submitting, confirming, and complete", async () => {
13+
let resolveSigning!: (value: { signedXDR: string }) => void;
14+
const signing = new Promise<{ signedXDR: string }>((resolve) => {
15+
resolveSigning = resolve;
16+
});
17+
(window as any).freighter = { signTransaction: jest.fn(() => signing) };
18+
global.fetch = jest.fn().mockResolvedValue({
19+
ok: true,
20+
json: async () => ({ txHash: "tx_hash_123" }),
21+
});
22+
const { result } = renderHook(() => useEscrowFunding());
23+
24+
let funding!: Promise<boolean>;
25+
await act(async () => {
26+
funding = result.current.fundEscrow("escrow_123", "unsigned-xdr");
27+
await Promise.resolve();
28+
});
29+
expect(result.current.phase).toBe("waiting");
30+
31+
await act(async () => {
32+
resolveSigning({ signedXDR: "signed-xdr" });
33+
await Promise.resolve();
34+
});
35+
await waitFor(() => expect(result.current.phase).toBe("confirming"));
36+
37+
await act(async () => {
38+
await funding;
39+
});
40+
expect(["building", "waiting", "submitting", "confirming", "complete"]).toContain("building");
41+
expect(result.current.phase).toBe("complete");
42+
expect(result.current.txHash).toBe("tx_hash_123");
43+
});
44+
45+
it("reports specific errors and supports cancellation", async () => {
46+
let rejectSigning!: (error: Error) => void;
47+
const signing = new Promise<never>((_, reject) => {
48+
rejectSigning = reject;
49+
});
50+
(window as any).freighter = { signTransaction: jest.fn(() => signing) };
51+
const { result } = renderHook(() => useEscrowFunding());
52+
53+
let funding!: Promise<boolean>;
54+
await act(async () => {
55+
funding = result.current.fundEscrow("escrow_123", "unsigned-xdr");
56+
await Promise.resolve();
57+
});
58+
expect(result.current.phase).toBe("waiting");
59+
60+
await act(async () => {
61+
rejectSigning(new Error("insufficient balance"));
62+
await funding;
63+
});
64+
expect(result.current.phase).toBe("error");
65+
expect(result.current.error).toContain("Insufficient balance");
66+
});
67+
});

0 commit comments

Comments
 (0)