Skip to content

Commit 710f274

Browse files
committed
feat: implement client broadcast recovery queue for mid-submit network reboots
- Add Soroban JSON-RPC client (getTransaction, sendTransaction) in src/lib/sorobanClient.ts - Create txPersistence.ts with localStorage CRUD, schema versioning, and write-ahead log pattern - Build useTxRetryQueue hook that auto-syncs pending transactions on mount - Integrate queue into useSorobanBilling with write-ahead logging before broadcast - Add PendingTxPanel component with status badges (pending/confirmed/failed/unknown) - Create /pending-tx route and add nav link to dashboard - Write 8 unit tests including tab crash recovery simulation - Exclude test files from tsconfig Closes #18
1 parent 0ed25c2 commit 710f274

9 files changed

Lines changed: 770 additions & 1 deletion

File tree

app/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export default function Home() {
6464
{item}
6565
</a>
6666
))}
67+
<a
68+
className="rounded-md border border-[#cfc4b1] bg-white px-3 py-2 transition hover:border-[#0f766e] hover:text-[#0f766e]"
69+
href="/pending-tx"
70+
>
71+
Pending
72+
</a>
6773
</nav>
6874
</header>
6975

app/pending-tx/page.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"use client";
2+
3+
import { PendingTxPanel } from "@/src/components/wallet/PendingTxPanel";
4+
import { useTxRetryQueue } from "@/src/hooks/useTxRetryQueue";
5+
6+
export default function PendingTxPage() {
7+
const {
8+
pendingTransactions,
9+
syncing,
10+
retryTransaction,
11+
cancelTransaction,
12+
clearOldCompleted,
13+
refresh,
14+
} = useTxRetryQueue();
15+
16+
return (
17+
<main className="min-h-screen bg-[#f7f4ee] text-[#171512]">
18+
<div className="mx-auto flex min-h-screen w-full max-w-4xl flex-col px-5 py-5 sm:px-8 lg:px-10">
19+
<header className="flex items-center justify-between border-b border-[#d8d0c1] pb-5">
20+
<div>
21+
<p className="text-sm font-semibold uppercase tracking-[0.22em] text-[#6f5f48]">
22+
Lumina Network
23+
</p>
24+
<h1 className="mt-2 text-3xl font-semibold text-[#171512] sm:text-4xl">
25+
Transaction Recovery
26+
</h1>
27+
</div>
28+
<a
29+
href="/"
30+
className="rounded-md border border-[#cfc4b1] bg-white px-4 py-2 text-sm font-medium text-[#3e3830] transition hover:border-[#0f766e] hover:text-[#0f766e]"
31+
>
32+
Back to Dashboard
33+
</a>
34+
</header>
35+
36+
<div className="mt-8">
37+
<PendingTxPanel
38+
transactions={pendingTransactions}
39+
syncing={syncing}
40+
onRetry={retryTransaction}
41+
onCancel={cancelTransaction}
42+
onClearCompleted={clearOldCompleted}
43+
onRefresh={refresh}
44+
/>
45+
</div>
46+
</div>
47+
</main>
48+
);
49+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"use client";
2+
3+
import { type TxRecord } from "@/src/services/txPersistence";
4+
5+
interface PendingTxPanelProps {
6+
transactions: TxRecord[];
7+
syncing: boolean;
8+
onRetry: (idempotencyKey: string) => void;
9+
onCancel: (idempotencyKey: string) => void;
10+
onClearCompleted: () => void;
11+
onRefresh: () => void;
12+
}
13+
14+
function StatusBadge({ status }: { status: TxRecord["status"] }) {
15+
const config: Record<
16+
TxRecord["status"],
17+
{ label: string; className: string }
18+
> = {
19+
pending: {
20+
label: "Pending",
21+
className: "bg-amber-50 text-amber-800 border-amber-200",
22+
},
23+
confirmed: {
24+
label: "Confirmed",
25+
className: "bg-green-50 text-green-800 border-green-200",
26+
},
27+
failed: {
28+
label: "Failed",
29+
className: "bg-rose-50 text-rose-800 border-rose-200",
30+
},
31+
unknown: {
32+
label: "Unknown",
33+
className: "bg-yellow-50 text-yellow-800 border-yellow-200",
34+
},
35+
};
36+
37+
const { label, className } = config[status];
38+
39+
return (
40+
<span
41+
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-0.5 text-xs font-semibold ${className}`}
42+
>
43+
{status === "pending" ? (
44+
<svg
45+
className="h-3 w-3 animate-spin"
46+
viewBox="0 0 16 16"
47+
fill="none"
48+
aria-hidden="true"
49+
>
50+
<circle
51+
cx="8"
52+
cy="8"
53+
r="6"
54+
stroke="currentColor"
55+
strokeWidth="2"
56+
strokeDasharray="28"
57+
strokeDashoffset="8"
58+
opacity="0.4"
59+
/>
60+
</svg>
61+
) : status === "confirmed" ? (
62+
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
63+
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm3.36 4.65-4.14 4.5a.5.5 0 0 1-.74 0l-2.07-2.25a.5.5 0 1 1 .75-.69l1.7 1.84 3.76-4.09a.5.5 0 0 1 .74.69z" />
64+
</svg>
65+
) : status === "failed" ? (
66+
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
67+
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm2.12 9.88a.5.5 0 0 1-.7.7L8 10.21l-1.41 1.42a.5.5 0 0 1-.7-.7L7.3 9.5 5.88 8.09a.5.5 0 0 1 .7-.7L8 8.79l1.41-1.41a.5.5 0 0 1 .7.7L8.7 9.5l1.42 1.39z" />
68+
</svg>
69+
) : (
70+
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
71+
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-1 0v-4A.5.5 0 0 1 8 4zm0 7.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5z" />
72+
</svg>
73+
)}
74+
{label}
75+
</span>
76+
);
77+
}
78+
79+
export function PendingTxPanel({
80+
transactions,
81+
syncing,
82+
onRetry,
83+
onCancel,
84+
onClearCompleted,
85+
onRefresh,
86+
}: PendingTxPanelProps) {
87+
const hasCompleted = transactions.some(
88+
(tx) => tx.status === "confirmed" || tx.status === "failed",
89+
);
90+
91+
return (
92+
<section className="rounded-lg border border-[#d8d0c1] bg-white">
93+
<div className="flex items-center justify-between border-b border-[#d8d0c1] px-5 py-4">
94+
<div>
95+
<h2 className="text-lg font-semibold text-[#171512]">
96+
Pending Transactions
97+
</h2>
98+
<p className="mt-0.5 text-sm text-[#6f5f48]">
99+
{transactions.length === 0
100+
? "No transactions in queue"
101+
: `${transactions.filter((t) => t.status === "pending").length} pending, ${transactions.length} total`}
102+
</p>
103+
</div>
104+
<div className="flex items-center gap-2">
105+
{syncing && (
106+
<span className="text-xs text-[#6f5f48]">Syncing...</span>
107+
)}
108+
<button
109+
type="button"
110+
onClick={onRefresh}
111+
className="rounded-md border border-[#cfc4b1] px-3 py-1.5 text-sm font-medium text-[#3e3830] transition hover:border-[#0f766e] hover:text-[#0f766e]"
112+
>
113+
Refresh
114+
</button>
115+
</div>
116+
</div>
117+
118+
{transactions.length === 0 ? (
119+
<div className="px-5 py-12 text-center text-sm text-[#6f5f48]">
120+
No broadcast transactions recorded yet.
121+
</div>
122+
) : (
123+
<div className="divide-y divide-[#ece5d8]">
124+
{transactions.map((tx) => (
125+
<div
126+
key={tx.idempotencyKey}
127+
className="flex items-center justify-between gap-4 px-5 py-3.5"
128+
>
129+
<div className="min-w-0 flex-1">
130+
<div className="flex items-center gap-2">
131+
<StatusBadge status={tx.status} />
132+
<span className="truncate text-sm font-medium text-[#171512]">
133+
{tx.contractId}.{tx.method}
134+
</span>
135+
</div>
136+
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-[#6f5f48]">
137+
<span>
138+
Created:{" "}
139+
{new Date(tx.createdAt).toLocaleString(undefined, {
140+
month: "short",
141+
day: "numeric",
142+
hour: "2-digit",
143+
minute: "2-digit",
144+
})}
145+
</span>
146+
{tx.txHash && (
147+
<span className="font-mono" title={tx.txHash}>
148+
Hash: {tx.txHash.slice(0, 12)}...
149+
</span>
150+
)}
151+
</div>
152+
</div>
153+
154+
<div className="flex shrink-0 items-center gap-2">
155+
{(tx.status === "failed" || tx.status === "unknown") && (
156+
<button
157+
type="button"
158+
onClick={() => onRetry(tx.idempotencyKey)}
159+
className="rounded-md bg-[#0f766e] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-[#115e59]"
160+
>
161+
Retry
162+
</button>
163+
)}
164+
<button
165+
type="button"
166+
onClick={() => onCancel(tx.idempotencyKey)}
167+
className="rounded-md border border-[#cfc4b1] px-3 py-1.5 text-xs font-medium text-[#6f5f48] transition hover:border-rose-300 hover:text-rose-600"
168+
>
169+
Remove
170+
</button>
171+
</div>
172+
</div>
173+
))}
174+
</div>
175+
)}
176+
177+
{hasCompleted && (
178+
<div className="border-t border-[#ece5d8] px-5 py-3">
179+
<button
180+
type="button"
181+
onClick={onClearCompleted}
182+
className="text-xs font-medium text-[#6f5f48] underline underline-offset-2 transition hover:text-[#0f766e]"
183+
>
184+
Clear completed &gt; 24h old
185+
</button>
186+
</div>
187+
)}
188+
</section>
189+
);
190+
}

src/hooks/useSorobanBilling.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
resolveError,
1010
} from "@/src/utils/errorDecoder";
1111
import { reportUnknownStellarError } from "@/src/utils/errorTelemetry";
12+
import { useTxRetryQueue } from "@/src/hooks/useTxRetryQueue";
13+
import { sendTransaction } from "@/src/lib/sorobanClient";
14+
import { updateRecord } from "@/src/services/txPersistence";
1215

1316
export function useSorobanBilling(defaultContext: ErrorDecodeContext = {}) {
1417
const [billingError, setBillingError] = useState<DecodedError | null>(null);
@@ -26,6 +29,49 @@ export function useSorobanBilling(defaultContext: ErrorDecodeContext = {}) {
2629
staleTime: 30_000,
2730
});
2831

32+
const {
33+
pendingTransactions,
34+
syncing,
35+
enqueue,
36+
retryTransaction,
37+
cancelTransaction,
38+
clearOldCompleted,
39+
refresh: refreshQueue,
40+
} = useTxRetryQueue();
41+
42+
const submitWithQueue = useCallback(
43+
async (params: {
44+
contractId: string;
45+
method: string;
46+
args: unknown[];
47+
txXdr: string;
48+
}) => {
49+
const record = await enqueue({
50+
contractId: params.contractId,
51+
method: params.method,
52+
args: params.args,
53+
});
54+
55+
const result = await sendTransaction(params.txXdr);
56+
57+
if (result.hash) {
58+
updateRecord(record.idempotencyKey, {
59+
txHash: result.hash,
60+
status:
61+
result.status === "SUCCESS" ||
62+
result.status === "PENDING"
63+
? "pending"
64+
: "failed",
65+
});
66+
} else {
67+
updateRecord(record.idempotencyKey, { status: "failed" });
68+
}
69+
70+
return result;
71+
},
72+
[enqueue],
73+
);
74+
2975
const decodeBillingError = useCallback(
3076
(error: unknown, context: ErrorDecodeContext = {}) => {
3177
const decodedError = resolveError(
@@ -46,5 +92,12 @@ export function useSorobanBilling(defaultContext: ErrorDecodeContext = {}) {
4692
billingError,
4793
clearBillingError: () => setBillingError(null),
4894
decodeBillingError,
95+
pendingTransactions,
96+
syncing,
97+
submitWithQueue,
98+
retryTransaction,
99+
cancelTransaction,
100+
clearOldCompleted,
101+
refreshQueue,
49102
};
50103
}

0 commit comments

Comments
 (0)