Skip to content

Commit

Permalink
chore(live-wallet): implement Wallet Sync <> Accounts management
Browse files Browse the repository at this point in the history
  • Loading branch information
gre committed Jun 18, 2024
1 parent 162219e commit 0099705
Show file tree
Hide file tree
Showing 9 changed files with 711 additions and 5 deletions.
20 changes: 19 additions & 1 deletion apps/web-tools/trustchain/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dynamic from "next/dynamic";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import { Tooltip } from "react-tooltip";
Expand Down Expand Up @@ -76,6 +77,8 @@ const App = () => {
? "PROD"
: "MIXED";

// TODO when Accounts Sync is on, we need to share the WS SDK instance and the Wallet Sync SDK section becomes read only. the version is also shared top level (so are accounts?!) in order for us to display Version: X next to the title

return (
<TrustchainSDKContext.Provider value={sdk}>
<Container>
Expand Down Expand Up @@ -214,18 +217,33 @@ const App = () => {
</Expand>
</Expand>

<Expand title="Wallet Sync" expanded={!!trustchain}>
<Expand title="Wallet Sync SDK">
{trustchain && memberCredentials ? (
<AppWalletSync trustchain={trustchain} memberCredentials={memberCredentials} />
) : (
"Please create a trustchain first"
)}
</Expand>

<Expand title="Accounts Sync" dynamic>
{trustchain && memberCredentials ? (
<AppAccountsSync trustchain={trustchain} memberCredentials={memberCredentials} />
) : (
"Prease create a trustchain first"
)}
</Expand>

<Tooltip id="tooltip" />
</Container>
</TrustchainSDKContext.Provider>
);
};

const AppAccountsSync = dynamic<{
trustchain: Trustchain;
memberCredentials: MemberCredentials;
}>(() => import("./AppAccountsSync"), {
loading: () => <p>Loading...</p>,
});

export default App;
340 changes: 340 additions & 0 deletions apps/web-tools/trustchain/components/AppAccountsSync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
import "../../live-common-setup";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Observable, concat, find, from, ignoreElements, mergeMap, tap } from "rxjs";
import { JWT, MemberCredentials, Trustchain } from "@ledgerhq/trustchain/types";
import { useTrustchainSDK } from "../context";
import { WalletSyncSDK, UpdateEvent } from "@ledgerhq/live-wallet/walletsync/index";
import {
diffWalletSyncState,
resolveWalletSyncDiffIntoSyncUpdate,
} from "@ledgerhq/live-wallet/walletsync/state";
import {
WalletState,
initialState as walletInitialState,
handlers as walletH,
inferLocalToDistantDiff,
accountNameSelector,
accountNameWithDefaultSelector,
} from "@ledgerhq/live-wallet/store";
import { getAccountBridge, getCurrencyBridge } from "@ledgerhq/live-common/bridge/index";
import { Account, BridgeCacheSystem } from "@ledgerhq/types-live";
import { makeBridgeCacheSystem } from "@ledgerhq/live-common/bridge/cache";
import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies";
import { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
import connectApp from "@ledgerhq/live-common/hw/connectApp";
import { formatCurrencyUnit } from "@ledgerhq/coin-framework/currencies/formatCurrencyUnit";
import { staticDeviceId } from "../device";
import { listSupportedCurrencies } from "@ledgerhq/coin-framework/lib-es/currencies/support";

export default function AppAccountsSync({
trustchain,
memberCredentials,
}: {
trustchain: Trustchain;
memberCredentials: MemberCredentials;
}) {
const trustchainSdk = useTrustchainSDK();

const [state, setState] = useState<{
accounts: Account[];
walletState: WalletState;
}>({
accounts: [],
walletState: walletInitialState,
});
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
const { version } = state.walletState.wsState;

const getCurrentVersion = useCallback(() => stateRef.current.walletState.wsState.version, []);

const bridgeCache = useMemo(() => {
const localCache: Record<string, unknown> = {};
const cache = makeBridgeCacheSystem({
saveData(c, d) {
localCache[c.id] = d;
return Promise.resolve();
},
getData(c) {
return Promise.resolve(localCache[c.id]);
},
});
return cache;
}, []);

const saveNewUpdate = useCallback(
async (event: UpdateEvent) => {
// in this current version, we just display the data as is, but in real app we would first reconciliate the account data and manage the sync
switch (event.type) {
case "new-data": {
const version = event.version;
const data = event.data;
const wsState = stateRef.current.walletState.wsState;

// TODO there remain a case to solve: when an account failed to be resolved in the past, we would need to eventually retry it. we could do this basically by adding in the resolved the failed

const diff = diffWalletSyncState(wsState, { version, data });
console.log("diffWalletSyncState", diff);
const resolved = await resolveWalletSyncDiffIntoSyncUpdate(
diff,
getAccountBridge,
bridgeCache,
);
console.log("resolveWalletSyncDiffIntoSyncUpdate", resolved);

setState(s => {
// apply the sync accounts update on the accounts
const removed = new Set(resolved.removed);
const accounts = [...s.accounts.filter(a => !removed.has(a.id)), ...resolved.added];
// apply the sync accounts update on the walletState (it will be reduced if integrated with redux)
const walletState = walletH.WALLET_SYNC_UPDATE(s.walletState, {
payload: resolved,
});
return { accounts, walletState };
});
break;
}
case "pushed-data": {
const version = event.version;
setState(s => ({ ...s, version }));
break;
}
case "deleted-data":
setState(s => ({ ...s, version: 0, data: null }));
break;
}
},
[bridgeCache],
);

const walletSyncSdk = useMemo(
() => new WalletSyncSDK({ trustchainSdk, getCurrentVersion, saveNewUpdate }),
[trustchainSdk, getCurrentVersion, saveNewUpdate],
);

// pull and push wallet sync loop
useEffect(() => {
const pollingInterval = 10000;
let pending = false;
const interval = setInterval(async () => {
// skip if there is something already pending
if (pending) return;
pending = true;
try {
const jwt = await trustchainSdk.auth(trustchain, memberCredentials);

// check if there is a pull to do
await walletSyncSdk.pull(jwt, trustchain);

// check if there is a push to do
const diff = inferLocalToDistantDiff(
stateRef.current.accounts,
stateRef.current.walletState,
);
console.log("inferLocalToDistantDiff:", diff);
if (diff.hasChanges && diff.newState.data) {
await walletSyncSdk.push(jwt, trustchain, diff.newState.data);
}
} catch (e) {
console.error("FIXME: error handling", e);
} finally {
pending = false;
}
}, pollingInterval);
return () => clearInterval(interval);
}, [trustchainSdk, walletSyncSdk, trustchain, memberCredentials]);

const setAccounts = useCallback(
(fn: (_: Account[]) => Account[]) => {
setState(s => ({ ...s, accounts: fn(s.accounts) }));
},
[setState],
);

return (
<div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
}}
>
<strong>Version: {version}</strong>
</div>
<HeadlessAddAccounts bridgeCache={bridgeCache} setAccounts={setAccounts} />
<HeadlessShowAccounts
walletState={state.walletState}
accounts={state.accounts}
setAccounts={setAccounts}
/>
</div>
);
}

// This is the part that handles adding accounts with a device
// at the moment, when user client "Add accounts" we directly assume the device is connected and already on the correct app
// in future, we can explore automation on automatically detecting the device, app and triggering the appropriate open app action to do so
// this is what the component <DeviceAction> offers in ledger-live-desktop , but we went minimalistic here. In future, device-sdk should have this in scope.
function HeadlessAddAccounts({
bridgeCache,
setAccounts,
}: {
bridgeCache: BridgeCacheSystem;
setAccounts: (_: (_: Account[]) => Account[]) => void;
}) {
// merge accounts with the existing ones
const addAccounts = useCallback(
(accounts: Account[]) => {
setAccounts(state => {
const existingSet = new Set(state.map(a => a.id));
return state.concat(accounts.filter(a => !existingSet.has(a.id)));
});
},
[setAccounts],
);

const [disabled, setDisabled] = useState(false);

const onSubmit = useCallback(
(e: any) => {
e.preventDefault();
if (!e.target) return;
const data = new FormData(e.target);
const currencyId = data.get("currency");
if (!currencyId) return;
setDisabled(true);
const deviceId = staticDeviceId;
// This is how we scan for accounts with the bridge today
const currency = getCryptoCurrencyById(String(currencyId));
const currencyBridge = getCurrencyBridge(currency);
const sub = appForCurrency(deviceId, currency, () =>
concat(
from(bridgeCache.prepareCurrency(currency)).pipe(ignoreElements()),
currencyBridge.scanAccounts({
currency,
deviceId,
syncConfig: {
paginationConfig: {},
blacklistedTokenIds: [],
},
}),
),
).subscribe({
next: event => {
if (event.type === "discovered") {
addAccounts([event.account]);
}
},
complete: () => {
setDisabled(false);
},
error: error => {
console.error(error);
setDisabled(false);
},
});
// TODO we could also offer an interruptability by doing sub.unsubscribe() when the user wants to cancel
return () => {
sub.unsubscribe();
};
},
[addAccounts, bridgeCache],
);
return (
<div>
<form onSubmit={onSubmit}>
<label htmlFor="currency">
<span>Currency</span>
<select name="currency" id="currency">
<option value="">Select a currency</option>
{listSupportedCurrencies().map(c => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<button type="submit" disabled={disabled}>
Add accounts
</button>
</form>
</div>
);
}

// This is the part that just show the accounts as they are.
// there is just an action to delete on each action, convenient as i've made the previous add accounts to directly append into the list
// we may totally change this state management paradigm if we want
function HeadlessShowAccounts({
walletState,
accounts,
setAccounts,
}: {
walletState: WalletState;
accounts: Account[];
setAccounts: (_: (_: Account[]) => Account[]) => void;
}) {
const removeAccount = useCallback(
(accountId: string) => {
setAccounts(state => state.filter(a => a.id !== accountId));
},
[setAccounts],
);

if (accounts.length === 0) {
return <div>No accounts.</div>;
}
return (
<div>
<ul style={{ padding: 0, margin: 0 }}>
{accounts.map(account => (
<li
key={account.id}
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 10,
}}
>
<strong>{accountNameWithDefaultSelector(walletState, account)}</strong>
<span>
{formatCurrencyUnit(account.currency.units[0], account.balance, { showCode: true })}
</span>
<code style={{ fontSize: "0.8em" }}>block#{account.blockHeight}</code>
<code>{account.freshAddress}</code>
<span>
<button type="button" onClick={() => removeAccount(account.id)}>
{" "}
Remove
</button>
</span>
</li>
))}
</ul>
</div>
);
}

// thin headless wrapper to first do the logic that will drive all the calls to access the app (NB: we may want to hook UI to it in future)
function appForCurrency<T>(
deviceId: string,
currency: CryptoCurrency,
job: () => Observable<T>,
): Observable<T> {
return connectApp({
deviceId,
request: {
appName: currency.managerAppName,
allowPartialDependencies: false,
},
}).pipe(
tap(e => console.log("connectApp", e)),
find(e => e.type === "opened"),
mergeMap(job),
);
}
Loading

0 comments on commit 0099705

Please sign in to comment.