Skip to content

Commit 91722a8

Browse files
⚡ (ledger-button): Wire getAccountWithBalance observable with frontend (#277)
2 parents 3c34961 + c94f8b2 commit 91722a8

File tree

9 files changed

+178
-44
lines changed

9 files changed

+178
-44
lines changed

packages/ledger-button-core/src/api/LedgerButtonCore.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { DeviceStatus } from "@ledgerhq/device-management-kit";
22
import { Container, Factory } from "inversify";
3-
import { lastValueFrom, Observable, tap } from "rxjs";
3+
import { Observable, tap } from "rxjs";
44

55
import { ButtonCoreContext } from "./model/ButtonCoreContext.js";
66
import { JSONRPCRequest } from "./model/eip/EIPTypes.js";
@@ -19,6 +19,7 @@ import {
1919
Account,
2020
type AccountService,
2121
} from "../internal/account/service/AccountService.js";
22+
import { FetchAccountsUseCase } from "../internal/account/use-case/fetchAccountsUseCase.js";
2223
import { FetchAccountsWithBalanceUseCase } from "../internal/account/use-case/fetchAccountsWithBalanceUseCase.js";
2324
import type { GetDetailedSelectedAccountUseCase } from "../internal/account/use-case/getDetailedSelectedAccountUseCase.js";
2425
import { backendModuleTypes } from "../internal/backend/backendModuleTypes.js";
@@ -247,29 +248,20 @@ export class LedgerButtonCore {
247248
.execute({ type });
248249
}
249250

250-
// Account methods
251-
async fetchAccounts(): Promise<Account[]> {
252-
this._logger.debug("Fetching accounts");
253-
const accounts = this.container
254-
.get<FetchAccountsWithBalanceUseCase>(
255-
accountModuleTypes.FetchAccountsWithBalanceUseCase,
256-
)
251+
async fetchAccountsFromCloudSync(): Promise<Account[]> {
252+
this._logger.debug("Fetching accounts from CloudSync");
253+
return this.container
254+
.get<FetchAccountsUseCase>(accountModuleTypes.FetchAccountsUseCase)
257255
.execute();
258-
259-
const accountsWithBalance: Account[] = await lastValueFrom(accounts);
260-
261-
this.container
262-
.get<AccountService>(accountModuleTypes.AccountService)
263-
.setAccounts(accountsWithBalance);
264-
265-
return accountsWithBalance;
266256
}
267257

268-
getAccounts() {
269-
this._logger.debug("Getting accounts");
258+
getAccounts(): Observable<Account[]> {
259+
this._logger.debug("Getting accounts with balance observable");
270260
return this.container
271-
.get<AccountService>(accountModuleTypes.AccountService)
272-
.getAccounts();
261+
.get<FetchAccountsWithBalanceUseCase>(
262+
accountModuleTypes.FetchAccountsWithBalanceUseCase,
263+
)
264+
.execute();
273265
}
274266

275267
selectAccount(account: Account) {

packages/ledger-button-core/src/internal/account/use-case/fetchAccountsWithBalanceUseCase.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { lastValueFrom, toArray } from "rxjs";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33

4-
import type { Account } from "../service/AccountService.js";
4+
import type { Account, AccountService } from "../service/AccountService.js";
55
import { FetchAccountsUseCase } from "./fetchAccountsUseCase.js";
66
import { FetchAccountsWithBalanceUseCase } from "./fetchAccountsWithBalanceUseCase.js";
77
import { HydrateAccountWithBalanceUseCase } from "./HydrateAccountWithBalanceUseCase.js";
@@ -80,6 +80,9 @@ function createMockHydrateImplementation(
8080
}
8181
describe("FetchAccountsWithBalanceUseCase", () => {
8282
let useCase: FetchAccountsWithBalanceUseCase;
83+
let mockAccountService: {
84+
getAccounts: ReturnType<typeof vi.fn>;
85+
};
8386
let mockFetchAccountsUseCase: {
8487
execute: ReturnType<typeof vi.fn>;
8588
};
@@ -88,6 +91,9 @@ describe("FetchAccountsWithBalanceUseCase", () => {
8891
};
8992

9093
beforeEach(() => {
94+
mockAccountService = {
95+
getAccounts: vi.fn().mockReturnValue([]),
96+
};
9197
mockFetchAccountsUseCase = {
9298
execute: vi.fn(),
9399
};
@@ -97,11 +103,13 @@ describe("FetchAccountsWithBalanceUseCase", () => {
97103

98104
useCase = new FetchAccountsWithBalanceUseCase(
99105
createMockLoggerFactory(),
106+
mockAccountService as unknown as AccountService,
100107
mockFetchAccountsUseCase as unknown as FetchAccountsUseCase,
101108
mockHydrateAccountWithBalanceUseCase as unknown as HydrateAccountWithBalanceUseCase,
102109
);
103110

104111
vi.clearAllMocks();
112+
mockAccountService.getAccounts.mockReturnValue([]);
105113
});
106114

107115
describe("execute", () => {
@@ -224,5 +232,28 @@ describe("FetchAccountsWithBalanceUseCase", () => {
224232
// account-2 should have balance loaded
225233
expect(finalEmission[1].balance).toBe(USDT_BALANCE);
226234
});
235+
236+
it("should use existing accounts from AccountService and stream balances without calling FetchAccountsUseCase", async () => {
237+
const account1 = createMockAccount(mockEthAccountValue);
238+
const account2 = createMockAccount(mockUsdtAccountValue);
239+
const existingAccounts = [account1, account2];
240+
241+
mockAccountService.getAccounts.mockReturnValue(existingAccounts);
242+
mockHydrateAccountWithBalanceUseCase.execute.mockImplementation(
243+
createMockHydrateImplementation(ETH_BALANCE, USDT_BALANCE),
244+
);
245+
246+
const emissions = await lastValueFrom(useCase.execute().pipe(toArray()));
247+
248+
expect(mockFetchAccountsUseCase.execute).not.toHaveBeenCalled();
249+
expect(emissions.length).toBeGreaterThan(0);
250+
expect(emissions[0]).toHaveLength(2);
251+
expect(emissions[0][0].balance).toBeUndefined();
252+
expect(emissions[0][1].balance).toBeUndefined();
253+
254+
const finalEmission = emissions[emissions.length - 1];
255+
expect(finalEmission[0].balance).toBe(ETH_BALANCE);
256+
expect(finalEmission[1].balance).toBe(USDT_BALANCE);
257+
});
227258
});
228259
});

packages/ledger-button-core/src/internal/account/use-case/fetchAccountsWithBalanceUseCase.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import {
1414
import { loggerModuleTypes } from "../../logger/loggerModuleTypes.js";
1515
import { LoggerPublisher } from "../../logger/service/LoggerPublisher.js";
1616
import { accountModuleTypes } from "../accountModuleTypes.js";
17-
import type { Account, AccountUpdate } from "../service/AccountService.js";
17+
import type {
18+
Account,
19+
AccountService,
20+
AccountUpdate,
21+
} from "../service/AccountService.js";
1822
import { FetchAccountsUseCase } from "./fetchAccountsUseCase.js";
1923
import { HydrateAccountWithBalanceUseCase } from "./HydrateAccountWithBalanceUseCase.js";
2024

@@ -25,6 +29,8 @@ export class FetchAccountsWithBalanceUseCase {
2529
constructor(
2630
@inject(loggerModuleTypes.LoggerPublisher)
2731
loggerFactory: Factory<LoggerPublisher>,
32+
@inject(accountModuleTypes.AccountService)
33+
private readonly accountService: AccountService,
2834
@inject(accountModuleTypes.FetchAccountsUseCase)
2935
private readonly fetchAccountsUseCase: FetchAccountsUseCase,
3036
@inject(accountModuleTypes.HydrateAccountWithBalanceUseCase)
@@ -34,7 +40,13 @@ export class FetchAccountsWithBalanceUseCase {
3440
}
3541

3642
execute(): Observable<Account[]> {
37-
return from(this.fetchAccountsUseCase.execute()).pipe(
43+
const existingAccounts = this.accountService.getAccounts();
44+
const accountsSource =
45+
existingAccounts.length > 0
46+
? of(existingAccounts)
47+
: from(this.fetchAccountsUseCase.execute());
48+
49+
return accountsSource.pipe(
3850
switchMap((accounts) => {
3951
const initialAccounts =
4052
this.initializeAccountsWithEmptyBalances(accounts);

packages/ledger-button/src/components/atom/skeleton/ledger-skeleton.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export class LedgerSkeleton extends LitElement {
1616
return html`
1717
<div
1818
data-slot="skeleton"
19-
class="lb-h-full lb-w-full lb-animate-pulse lb-rounded-[inherit] lb-bg-muted"
19+
class="lb-h-full lb-w-full lb-animate-pulse lb-rounded-[inherit] lb-bg-muted-transparent"
2020
role="presentation"
2121
aria-hidden="true"
2222
></div>

packages/ledger-button/src/components/molecule/account-item/ledger-account-item.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ export class LedgerAccountItemMolecule extends LitElement {
143143
if (this.isBalanceLoading) {
144144
return html`
145145
<div class="lb-flex lb-items-center lb-justify-center">
146-
<ledger-skeleton class="lb-h-16 lb-w-80"></ledger-skeleton>
146+
<ledger-skeleton
147+
class="lb-h-16 lb-w-80 lb-rounded-full"
148+
></ledger-skeleton>
147149
</div>
148150
`;
149151
}
@@ -175,7 +177,9 @@ export class LedgerAccountItemMolecule extends LitElement {
175177
<div
176178
class="lb-flex lb-items-center lb-justify-between lb-border lb-border-b-0 lb-border-l-0 lb-border-r-0 lb-border-muted-subtle lb-bg-muted lb-p-12"
177179
>
178-
<ledger-skeleton class="lb-h-16 lb-w-112"></ledger-skeleton>
180+
<ledger-skeleton
181+
class="lb-h-16 lb-w-112 lb-rounded-full"
182+
></ledger-skeleton>
179183
</div>
180184
`;
181185
}

packages/ledger-button/src/domain/account-tokens/account-token-controller.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Account } from "@ledgerhq/ledger-wallet-provider-core";
22
import { ReactiveController, ReactiveControllerHost } from "lit";
3+
import { Subscription } from "rxjs";
34

45
import { CoreContext } from "../../context/core-context";
56
import { Navigation } from "../../shared/navigation";
67
import { RootNavigationComponent } from "../../shared/root-navigation";
78

89
export class AccountTokenController implements ReactiveController {
910
account: Account | null = null;
11+
private accountsSubscription?: Subscription;
1012

1113
constructor(
1214
private readonly host: ReactiveControllerHost,
@@ -22,22 +24,41 @@ export class AccountTokenController implements ReactiveController {
2224
this.getAccount();
2325
}
2426

27+
hostDisconnected() {
28+
if (this.accountsSubscription) {
29+
this.accountsSubscription.unsubscribe();
30+
this.accountsSubscription = undefined;
31+
}
32+
}
33+
2534
getAccount() {
2635
const targetId = this.core.getPendingAccountId();
2736
if (!targetId) {
2837
this.navigation.navigateBack();
2938
return;
3039
}
31-
this.account = this.core
32-
.getAccounts()
33-
.find((acc) => acc.id === targetId) as Account | null;
3440

35-
// If the account is not found, navigate back to account list
36-
if (!this.account) {
37-
this.navigation.navigateBack();
41+
if (this.accountsSubscription) {
42+
this.accountsSubscription.unsubscribe();
3843
}
3944

40-
this.host.requestUpdate();
45+
this.accountsSubscription = this.core.getAccounts().subscribe({
46+
next: (accounts) => {
47+
this.account =
48+
accounts.find((acc: Account) => acc.id === targetId) ?? null;
49+
50+
if (!this.account) {
51+
this.navigation.navigateBack();
52+
}
53+
54+
this.host.requestUpdate();
55+
},
56+
error: (error) => {
57+
console.error("Failed to fetch accounts", error);
58+
this.navigation.navigateBack();
59+
this.host.requestUpdate();
60+
},
61+
});
4162
}
4263

4364
handleConnect = () => {

packages/ledger-button/src/domain/onboarding/retrieving-accounts/retrieving-accounts-controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class RetrievingAccountsController implements ReactiveController {
3535

3636
async fetchAccounts() {
3737
try {
38-
const accounts = await this.core.fetchAccounts();
38+
const accounts = await this.core.fetchAccountsFromCloudSync();
3939
this.host.requestUpdate();
4040

4141
if (!accounts || accounts.length === 0) {

packages/ledger-button/src/domain/onboarding/select-account/select-account-controller.ts

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@ import "../../../shared/root-navigation.js";
22

33
import { Account } from "@ledgerhq/ledger-wallet-provider-core";
44
import { ReactiveController, ReactiveControllerHost } from "lit";
5+
import { Subscription } from "rxjs";
56

67
import type { AccountItemClickEventDetail } from "../../../components/molecule/account-item/ledger-account-item.js";
78
import { CoreContext } from "../../../context/core-context.js";
89
import { Navigation } from "../../../shared/navigation.js";
910
import { RootNavigationComponent } from "../../../shared/root-navigation.js";
11+
12+
type BalanceLoadingState = "loading" | "loaded" | "error";
13+
1014
export class SelectAccountController implements ReactiveController {
1115
accounts: Account[] = [];
16+
isAccountsLoading = false;
17+
balanceLoadingStates = new Map<string, BalanceLoadingState>();
18+
private accountsSubscription?: Subscription;
1219

1320
get isBalanceLoading(): boolean {
1421
return this.accounts.some((account) => account.balance === undefined);
@@ -26,10 +33,72 @@ export class SelectAccountController implements ReactiveController {
2633
this.getAccounts();
2734
}
2835

29-
async getAccounts() {
30-
const accounts = await this.core.getAccounts();
31-
this.accounts = accounts ?? [];
36+
hostDisconnected() {
37+
if (this.accountsSubscription) {
38+
this.accountsSubscription.unsubscribe();
39+
this.accountsSubscription = undefined;
40+
}
41+
}
42+
43+
getAccounts() {
44+
console.log("select-account-controller: getAccounts");
45+
46+
if (this.accountsSubscription) {
47+
this.accountsSubscription.unsubscribe();
48+
}
49+
50+
this.isAccountsLoading = true;
3251
this.host.requestUpdate();
52+
53+
this.accountsSubscription = this.core.getAccounts().subscribe({
54+
next: (accounts) => {
55+
this.accounts = accounts;
56+
this.updateBalanceLoadingStates(accounts);
57+
if (this.isAccountsLoading) {
58+
this.isAccountsLoading = false;
59+
}
60+
this.host.requestUpdate();
61+
},
62+
error: (error) => {
63+
this.isAccountsLoading = false;
64+
console.error("Failed to fetch accounts with balance", error);
65+
this.host.requestUpdate();
66+
},
67+
});
68+
}
69+
70+
private updateBalanceLoadingStates(accounts: Account[]) {
71+
for (const account of accounts) {
72+
if (account.balance !== undefined) {
73+
this.balanceLoadingStates.set(account.id, "loaded");
74+
} else {
75+
const currentState = this.balanceLoadingStates.get(account.id);
76+
if (currentState !== "error") {
77+
this.balanceLoadingStates.set(account.id, "loading");
78+
}
79+
}
80+
}
81+
}
82+
83+
setBalanceLoadingState(accountId: string, state: BalanceLoadingState): void {
84+
this.balanceLoadingStates.set(accountId, state);
85+
this.host.requestUpdate();
86+
}
87+
88+
getBalanceLoadingState(accountId: string): BalanceLoadingState | undefined {
89+
return this.balanceLoadingStates.get(accountId);
90+
}
91+
92+
isAccountBalanceLoading(accountId: string): boolean {
93+
return this.balanceLoadingStates.get(accountId) === "loading";
94+
}
95+
96+
isAccountBalanceLoaded(accountId: string): boolean {
97+
return this.balanceLoadingStates.get(accountId) === "loaded";
98+
}
99+
100+
hasAccountBalanceError(accountId: string): boolean {
101+
return this.balanceLoadingStates.get(accountId) === "error";
33102
}
34103

35104
selectAccount(account: Account) {
@@ -42,9 +111,9 @@ export class SelectAccountController implements ReactiveController {
42111
handleAccountItemClick = (
43112
event: CustomEvent<AccountItemClickEventDetail>,
44113
) => {
45-
const account = this.core
46-
.getAccounts()
47-
.find((acc) => acc.id === event.detail.ledgerId);
114+
const account = this.accounts.find(
115+
(acc: Account) => acc.id === event.detail.ledgerId,
116+
);
48117

49118
if (account) {
50119
this.selectAccount(account);
@@ -67,9 +136,9 @@ export class SelectAccountController implements ReactiveController {
67136
handleAccountItemShowTokensClick = (
68137
event: CustomEvent<AccountItemClickEventDetail>,
69138
) => {
70-
const account = this.core
71-
.getAccounts()
72-
.find((acc) => acc.id === event.detail.ledgerId);
139+
const account = this.accounts.find(
140+
(acc: Account) => acc.id === event.detail.ledgerId,
141+
);
73142

74143
if (account) {
75144
this.core.setPendingAccountId(account.id);

0 commit comments

Comments
 (0)