-
Notifications
You must be signed in to change notification settings - Fork 327
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(live-wallet): implement Wallet Sync <> Accounts management
- Loading branch information
Showing
9 changed files
with
711 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
340 changes: 340 additions & 0 deletions
340
apps/web-tools/trustchain/components/AppAccountsSync.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
); | ||
} |
Oops, something went wrong.