Skip to content

Commit 0348105

Browse files
✨ (ledger-button) [LBD-507]: Add transaction confirmation notifications (#450)
2 parents d1371a6 + 3c82a1c commit 0348105

32 files changed

Lines changed: 1246 additions & 44 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ledgerhq/ledger-wallet-provider": minor
3+
"@ledgerhq/ledger-wallet-provider-core": minor
4+
---
5+
6+
Add transaction confirmation notifications

apps/test-dapp/src/app/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,9 @@ export default function Index() {
414414
account={account}
415415
chainId={chainId}
416416
isInitialized={isInitialized}
417+
transactionConfirmationNotification={
418+
config.transactionConfirmationNotification
419+
}
417420
/>
418421
</div>
419422
<div className="flex min-h-0 flex-1 flex-col">

apps/test-dapp/src/components/ConnectionStatus.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ interface ConnectionStatusProps {
4242
account: string | null;
4343
chainId: string | null;
4444
isInitialized: boolean;
45+
transactionConfirmationNotification?: "tooltip" | "toast";
4546
}
4647

4748
export function ConnectionStatus({
4849
selectedProvider,
4950
account,
5051
chainId,
5152
isInitialized,
53+
transactionConfirmationNotification = "tooltip",
5254
}: ConnectionStatusProps) {
5355
if (!isInitialized) {
5456
return (
@@ -60,6 +62,17 @@ export function ConnectionStatus({
6062
</span>
6163
</div>
6264
<p className="body-2 text-muted">Initializing provider…</p>
65+
<div className="mt-12">
66+
<Tag
67+
appearance="gray"
68+
size="sm"
69+
label={
70+
transactionConfirmationNotification === "toast"
71+
? "Tx confirm: Toast"
72+
: "Tx confirm: Tooltip"
73+
}
74+
/>
75+
</div>
6376
</div>
6477
);
6578
}
@@ -77,6 +90,17 @@ export function ConnectionStatus({
7790
No provider connected. Discover and select a provider to begin
7891
testing.
7992
</p>
93+
<div className="mt-12">
94+
<Tag
95+
appearance="gray"
96+
size="sm"
97+
label={
98+
transactionConfirmationNotification === "toast"
99+
? "Tx confirm: Toast"
100+
: "Tx confirm: Tooltip"
101+
}
102+
/>
103+
</div>
80104
</div>
81105
);
82106
}
@@ -120,6 +144,21 @@ export function ConnectionStatus({
120144
{account && <CopyableValue label="Account" value={account} />}
121145

122146
{chainId && <CopyableValue label="Chain ID" value={chainId} />}
147+
148+
<div className="pt-14 border-t border-muted">
149+
<span className="body-2 text-muted block mb-8">
150+
Transaction confirmation
151+
</span>
152+
<Tag
153+
appearance="gray"
154+
size="sm"
155+
label={
156+
transactionConfirmationNotification === "toast"
157+
? "Toast notifications"
158+
: "Floating button tooltip"
159+
}
160+
/>
161+
</div>
123162
</div>
124163
);
125164
}

apps/test-dapp/src/components/SettingsBlock.tsx

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

3-
import { useCallback, useState } from "react";
3+
import { useCallback, useEffect, useState } from "react";
44
import {
55
Button,
66
Select,
@@ -16,6 +16,7 @@ import { ChevronDown, ChevronRight, Settings } from "@ledgerhq/lumen-ui-react/sy
1616
import {
1717
ALL_WALLET_FEATURES,
1818
type LedgerProviderConfig,
19+
type TransactionConfirmationNotification,
1920
type WalletTransactionFeature,
2021
} from "../hooks/useProviders";
2122

@@ -52,6 +53,14 @@ const ENVIRONMENTS = [
5253
{ value: "staging", label: "Staging" },
5354
];
5455

56+
const CONFIRMATION_NOTIFICATION_MODES: {
57+
value: TransactionConfirmationNotification;
58+
label: string;
59+
}[] = [
60+
{ value: "tooltip", label: "Tooltip" },
61+
{ value: "toast", label: "Toast" },
62+
];
63+
5564
export function SettingsBlock({
5665
config,
5766
onConfigChange,
@@ -71,6 +80,11 @@ export function SettingsBlock({
7180

7281
const dappSelectValue = isCustomDapp ? "custom" : localConfig.dAppIdentifier;
7382

83+
useEffect(() => {
84+
setLocalConfig(config);
85+
setLastAppliedConfig(config);
86+
}, [config]);
87+
7488
const handleInputChange = useCallback(
7589
(field: keyof LedgerProviderConfig, value: string) => {
7690
setLocalConfig((prev) => ({
@@ -115,6 +129,16 @@ export function SettingsBlock({
115129
[],
116130
);
117131

132+
const handleConfirmationModeChange = useCallback(
133+
(mode: TransactionConfirmationNotification) => {
134+
setLocalConfig((prev) => ({
135+
...prev,
136+
transactionConfirmationNotification: mode,
137+
}));
138+
},
139+
[],
140+
);
141+
118142
const handleApply = useCallback(() => {
119143
onConfigChange(localConfig);
120144
setLastAppliedConfig(localConfig);
@@ -140,8 +164,19 @@ export function SettingsBlock({
140164
{hasChanges && (
141165
<Tag appearance="warning" size="sm" label="Unsaved" />
142166
)}
167+
<Tag
168+
appearance="gray"
169+
size="sm"
170+
label={
171+
lastAppliedConfig.transactionConfirmationNotification === "toast"
172+
? "Confirm: Toast"
173+
: "Confirm: Tooltip"
174+
}
175+
/>
143176
</div>
144-
<span className="text-muted">{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</span>
177+
<span className="text-muted">
178+
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
179+
</span>
145180
</div>
146181

147182
{isExpanded && (
@@ -247,6 +282,37 @@ export function SettingsBlock({
247282
</Select>
248283
</div>
249284

285+
<div className="space-y-10">
286+
<h4 className="body-2-semi-bold text-muted uppercase tracking-wider">
287+
Transaction confirmation
288+
</h4>
289+
<p className="body-2 text-muted">
290+
How on-chain confirmation is shown after a pending transaction
291+
settles. Click Apply &amp; Reinitialize to switch modes.
292+
</p>
293+
<div className="flex flex-wrap gap-8">
294+
{CONFIRMATION_NOTIFICATION_MODES.map((mode) => {
295+
const isActive =
296+
localConfig.transactionConfirmationNotification ===
297+
mode.value;
298+
return (
299+
<button
300+
key={mode.value}
301+
type="button"
302+
onClick={() => handleConfirmationModeChange(mode.value)}
303+
className={`px-14 py-8 rounded-lg body-2-semi-bold cursor-pointer transition-colors border ${
304+
isActive
305+
? "border-active bg-muted-transparent text-base"
306+
: "border-muted bg-canvas text-muted"
307+
}`}
308+
>
309+
{mode.label}
310+
</button>
311+
);
312+
})}
313+
</div>
314+
</div>
315+
250316
<div className="space-y-10">
251317
<h4 className="body-2-semi-bold text-muted uppercase tracking-wider">
252318
Wallet Actions

apps/test-dapp/src/hooks/useProviders.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export const ALL_WALLET_FEATURES: WalletTransactionFeature[] = [
2222
"sell",
2323
];
2424

25+
export type TransactionConfirmationNotification = "tooltip" | "toast";
26+
2527
export interface LedgerProviderConfig {
2628
dAppIdentifier: string;
2729
apiKey: string;
@@ -30,6 +32,7 @@ export interface LedgerProviderConfig {
3032
logLevel: string;
3133
environment: string;
3234
walletTransactionFeatures: WalletTransactionFeature[];
35+
transactionConfirmationNotification: TransactionConfirmationNotification;
3336
}
3437

3538
export const DEFAULT_CONFIG: LedgerProviderConfig = {
@@ -40,6 +43,7 @@ export const DEFAULT_CONFIG: LedgerProviderConfig = {
4043
logLevel: "info",
4144
environment: "production",
4245
walletTransactionFeatures: ["send", "receive", "swap", "buy", "earn", "sell"],
46+
transactionConfirmationNotification: "tooltip",
4347
};
4448

4549
export const useProviders = (config: LedgerProviderConfig = DEFAULT_CONFIG) => {
@@ -111,6 +115,8 @@ export const useProviders = (config: LedgerProviderConfig = DEFAULT_CONFIG) => {
111115
environment: configToUse.environment as "production" | "staging",
112116
dmkConfig: undefined,
113117
walletTransactionFeatures: configToUse.walletTransactionFeatures,
118+
transactionConfirmationNotification:
119+
configToUse.transactionConfirmationNotification,
114120
devConfig: disableEventTracking
115121
? {
116122
stub: {

packages/ledger-button-core/src/internal/pending-transaction/controller/DefaultPendingTransactionController.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ describe("DefaultPendingTransactionController", () => {
177177
const tx = createPendingTx({ hash: "0x111" });
178178
mockStorageService._store.push(tx);
179179

180-
mockCheckPendingStatus.execute.mockResolvedValue(Right(["0x111"]));
180+
mockCheckPendingStatus.execute.mockResolvedValue(
181+
Right([{ hash: "0x111", failed: false }]),
182+
);
181183

182184
controller.track();
183185
await vi.advanceTimersByTimeAsync(10_000);
@@ -206,7 +208,9 @@ describe("DefaultPendingTransactionController", () => {
206208
it("should restart polling on new track after shutdown", async () => {
207209
const tx1 = createPendingTx({ hash: "0x111" });
208210
mockStorageService._store.push(tx1);
209-
mockCheckPendingStatus.execute.mockResolvedValue(Right(["0x111"]));
211+
mockCheckPendingStatus.execute.mockResolvedValue(
212+
Right([{ hash: "0x111", failed: false }]),
213+
);
210214

211215
controller.track();
212216
await vi.advanceTimersByTimeAsync(10_000);
@@ -311,12 +315,38 @@ describe("DefaultPendingTransactionController", () => {
311315
});
312316
});
313317

318+
describe("pending removal on confirmation", () => {
319+
it("should emit an empty pending list after confirmed transactions are removed from storage", async () => {
320+
const tx = createPendingTx({ hash: "0x111" });
321+
mockStorageService._store.push(tx);
322+
323+
mockCheckPendingStatus.execute.mockResolvedValue(
324+
Right([{ hash: "0x111", failed: false }]),
325+
);
326+
327+
const emissions: PendingTransaction[][] = [];
328+
const subscription = controller
329+
.observePendingTransactions()
330+
.subscribe((value) => emissions.push(value));
331+
332+
controller.track();
333+
await vi.advanceTimersByTimeAsync(10_000);
334+
335+
expect(emissions.at(-1)).toEqual([]);
336+
expect(mockStorageService.remove).toHaveBeenCalledWith("0x111");
337+
338+
subscription.unsubscribe();
339+
});
340+
});
341+
314342
describe("transaction history refresh", () => {
315343
it("should refresh transaction history when transactions are confirmed", async () => {
316344
const tx = createPendingTx({ hash: "0x111" });
317345
mockStorageService._store.push(tx);
318346

319-
mockCheckPendingStatus.execute.mockResolvedValue(Right(["0x111"]));
347+
mockCheckPendingStatus.execute.mockResolvedValue(
348+
Right([{ hash: "0x111", failed: false }]),
349+
);
320350

321351
controller.track();
322352
await vi.advanceTimersByTimeAsync(10_000);
@@ -341,7 +371,9 @@ describe("DefaultPendingTransactionController", () => {
341371
const tx2 = createPendingTx({ hash: "0x222" });
342372
mockStorageService._store.push(tx1, tx2);
343373

344-
mockCheckPendingStatus.execute.mockResolvedValue(Right(["0x111"]));
374+
mockCheckPendingStatus.execute.mockResolvedValue(
375+
Right([{ hash: "0x111", failed: false }]),
376+
);
345377
mockFetchSelectedAccount.execute.mockResolvedValue(
346378
Left(new Error("Refresh failed")),
347379
);

packages/ledger-button-core/src/internal/pending-transaction/controller/DefaultPendingTransactionController.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class DefaultPendingTransactionController
7070
preferredCurrency !== undefined &&
7171
context.preferredFiatCurrency !== preferredCurrency
7272
) {
73-
this.emitCurrentState();
73+
void this.emitCurrentState();
7474
}
7575
preferredCurrency = context.preferredFiatCurrency;
7676
});
@@ -118,15 +118,17 @@ export class DefaultPendingTransactionController
118118
return;
119119
}
120120

121-
const confirmedHashes = result.unsafeCoerce();
121+
const settledOutcomes = result.unsafeCoerce();
122122

123-
for (const hash of confirmedHashes) {
124-
this.storageService.remove(hash);
123+
if (settledOutcomes.length > 0) {
124+
for (const { hash } of settledOutcomes) {
125+
this.storageService.remove(hash);
126+
}
125127
}
126128

127-
this.emitCurrentState();
129+
await this.emitUpdate();
128130

129-
if (confirmedHashes.length > 0) {
131+
if (settledOutcomes.length > 0) {
130132
this.refreshSelectedAccount();
131133
}
132134

@@ -146,10 +148,15 @@ export class DefaultPendingTransactionController
146148
}
147149

148150
private async emitCurrentState(): Promise<void> {
149-
const hydratedTxs = this.hydratePendingTransactionsWithFiatUseCase.execute(
150-
this.storageService.getAll(),
151-
this.contextService.getContext().preferredFiatCurrency,
152-
);
153-
this.pendingTxSubject.next(await hydratedTxs);
151+
await this.emitUpdate();
152+
}
153+
154+
private async emitUpdate(): Promise<void> {
155+
const hydratedTxs =
156+
await this.hydratePendingTransactionsWithFiatUseCase.execute(
157+
this.storageService.getAll(),
158+
this.contextService.getContext().preferredFiatCurrency,
159+
);
160+
this.pendingTxSubject.next(hydratedTxs);
154161
}
155162
}

0 commit comments

Comments
 (0)