diff --git a/apps/web-tools/package.json b/apps/web-tools/package.json index 84f6fc56f3ad..b4ae277bba8d 100644 --- a/apps/web-tools/package.json +++ b/apps/web-tools/package.json @@ -29,11 +29,13 @@ "@ledgerhq/live-common": "workspace:^", "@ledgerhq/live-config": "workspace:^", "@ledgerhq/live-env": "workspace:^", + "@ledgerhq/live-wallet": "workspace:^", "@ledgerhq/logs": "workspace:^", "@ledgerhq/trustchain": "workspace:^", "@ledgerhq/types-cryptoassets": "workspace:^", "@ledgerhq/types-live": "workspace:^", "bignumber.js": "^9.1.2", + "brace": "^0.11.1", "bufferutil": "^4.0.7", "encoding": "^0.1.13", "eslint-config-next": "13.5.6", diff --git a/apps/web-tools/trustchain/components/App.tsx b/apps/web-tools/trustchain/components/App.tsx index 687e61b4c3f1..b23fa03413d9 100644 --- a/apps/web-tools/trustchain/components/App.tsx +++ b/apps/web-tools/trustchain/components/App.tsx @@ -8,7 +8,7 @@ import Expand from "./Expand"; import { getSdk } from "@ledgerhq/trustchain"; import { DisplayName, IdentityManager } from "./IdentityManager"; import { AppQRCodeCandidate } from "./AppQRCodeCandidate"; -import { SDKContext, defaultContext } from "../context"; +import { TrustchainSDKContext, defaultContext } from "../context"; import { AppQRCodeHost } from "./AppQRCodeHost"; import { AppMemberRow } from "./AppMemberRow"; import { AppDecryptUserData } from "./AppDecryptUserData"; @@ -22,6 +22,8 @@ import { AppInitLiveCredentials } from "./AppInitLiveCredentials"; import { AppMockEnv } from "./AppMockEnv"; import { AppSetTrustchainAPIEnv } from "./AppSetTrustchainAPIEnv"; import { AppRestoreTrustchain } from "./AppRestoreTrustchain"; +import { AppWalletSync } from "./AppWalletSync"; +import { AppSetCloudSyncAPIEnv } from "./AppSetCloudSyncAPIEnv"; const Container = styled.div` padding: 0 10px; @@ -64,9 +66,18 @@ const App = () => { const mockEnv = useEnv("MOCK"); const sdk = useMemo(() => getSdk(!!mockEnv, context), [mockEnv, context]); + const envTrustchainApiIsStg = useEnv("TRUSTCHAIN_API").includes("stg"); + const envWalletSyncApiIsStg = useEnv("WALLET_SYNC_API").includes("stg"); + const envSummary = mockEnv + ? "MOCK" + : envTrustchainApiIsStg && envWalletSyncApiIsStg + ? "STG" + : !envTrustchainApiIsStg && !envWalletSyncApiIsStg + ? "PROD" + : "MIXED"; return ( - +

Wallet Sync Trustchain Playground

@@ -93,12 +104,42 @@ const App = () => { /> - + + Environment{" "} + + {envSummary} + + + } + > + - + + Trustchain SDK{" "} + {trustchain ? ( + + {trustchain.rootId.slice(0, 6)}..{trustchain.rootId.slice(-6)} at{" "} + {trustchain.applicationPath} + + ) : null} + + } + expanded={!trustchain} + > { setDeviceJWT={setDeviceJWT} jwt={jwt} /> - - - + + + + + + + - - + + {trustchain && memberCredentials ? ( + + ) : ( + "Please create a trustchain first" + )}
-
+ ); }; diff --git a/apps/web-tools/trustchain/components/AppAuthenticate.tsx b/apps/web-tools/trustchain/components/AppAuthenticate.tsx index 46f2a460fccf..388694001981 100644 --- a/apps/web-tools/trustchain/components/AppAuthenticate.tsx +++ b/apps/web-tools/trustchain/components/AppAuthenticate.tsx @@ -1,8 +1,7 @@ import React, { useCallback } from "react"; - import { JWT, MemberCredentials, Trustchain } from "@ledgerhq/trustchain/types"; import { Actionable } from "./Actionable"; -import { useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; export function AppAuthenticate({ jwt, @@ -17,7 +16,7 @@ export function AppAuthenticate({ trustchain: Trustchain | null; deviceJWT: JWT | null; }) { - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const action = useCallback( (trustchain: Trustchain, memberCredentials: MemberCredentials) => diff --git a/apps/web-tools/trustchain/components/AppDecryptUserData.tsx b/apps/web-tools/trustchain/components/AppDecryptUserData.tsx index 2bdc67f31111..f1da73bfb3ec 100644 --- a/apps/web-tools/trustchain/components/AppDecryptUserData.tsx +++ b/apps/web-tools/trustchain/components/AppDecryptUserData.tsx @@ -1,33 +1,24 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { createQRCodeHostInstance } from "@ledgerhq/trustchain/qrcode/index"; +import React, { useCallback, useState } from "react"; import { crypto } from "@ledgerhq/hw-trustchain"; -import { InvalidDigitsError } from "@ledgerhq/trustchain/errors"; -import styled from "styled-components"; -import { Tooltip } from "react-tooltip"; -import { JWT, MemberCredentials, Trustchain, TrustchainMember } from "@ledgerhq/trustchain/types"; -import { getInitialStore } from "@ledgerhq/trustchain/store"; -import { Actionable, RenderActionable } from "./Actionable"; -import QRCode from "./QRCode"; -import useEnv from "../useEnv"; -import Expand from "./Expand"; -import { getSdk } from "@ledgerhq/trustchain"; -import { DisplayName, IdentityManager } from "./IdentityManager"; -import { AppQRCodeCandidate } from "./AppQRCodeCandidate"; +import { JWT, Trustchain } from "@ledgerhq/trustchain/types"; +import { Actionable } from "./Actionable"; import { Input } from "./Input"; -import { SDKContext, defaultContext, useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; export function AppDecryptUserData({ trustchain }: { trustchain: Trustchain | null }) { const [input, setInput] = useState(null); - const [output, setOutput] = useState<{ input: string } | null>(null); - const sdk = useSDK(); + const [output, setOutput] = useState(null); + const sdk = useTrustchainSDK(); const action = useCallback( (trustchain: Trustchain, input: string) => - sdk.decryptUserData(trustchain, crypto.from_hex(input)).then(obj => obj as { input: string }), + sdk + .decryptUserData(trustchain, crypto.from_hex(input)) + .then(array => new TextDecoder().decode(array)), [sdk], ); - const valueDisplay = useCallback((output: { input: string }) => {output.input}, []); + const valueDisplay = useCallback((output: string) => {output}, []); return ( void; setDeviceJWT: (deviceJWT: JWT | null) => void; }) { - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const action = useCallback( (trustchain: Trustchain, jwt: JWT) => sdk.destroyTrustchain(trustchain, jwt).then(() => { diff --git a/apps/web-tools/trustchain/components/AppDeviceAuthenticate.tsx b/apps/web-tools/trustchain/components/AppDeviceAuthenticate.tsx index fdcfbbca2334..ff0aa7eec26a 100644 --- a/apps/web-tools/trustchain/components/AppDeviceAuthenticate.tsx +++ b/apps/web-tools/trustchain/components/AppDeviceAuthenticate.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from "react"; import { Actionable } from "./Actionable"; -import { useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; import { runWithDevice } from "../device"; export function AppDeviceAuthenticate({ @@ -10,7 +10,7 @@ export function AppDeviceAuthenticate({ deviceJWT: { accessToken: string } | null; setDeviceJWT: (deviceJWT: { accessToken: string } | null) => void; }) { - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const action = useCallback( () => runWithDevice(transport => sdk.authWithDevice(transport)), diff --git a/apps/web-tools/trustchain/components/AppEncryptUserData.tsx b/apps/web-tools/trustchain/components/AppEncryptUserData.tsx index 637e4d896b2a..e93e3ffe0a3b 100644 --- a/apps/web-tools/trustchain/components/AppEncryptUserData.tsx +++ b/apps/web-tools/trustchain/components/AppEncryptUserData.tsx @@ -3,15 +3,16 @@ import { crypto } from "@ledgerhq/hw-trustchain"; import { Trustchain } from "@ledgerhq/trustchain/types"; import { Actionable } from "./Actionable"; import { Input } from "./Input"; -import { useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; export function AppEncryptUserData({ trustchain }: { trustchain: Trustchain | null }) { const [input, setInput] = useState(null); const [output, setOutput] = useState(null); - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const action = useCallback( - (trustchain: Trustchain, input: string) => sdk.encryptUserData(trustchain, { input }), + (trustchain: Trustchain, input: string) => + sdk.encryptUserData(trustchain, new TextEncoder().encode(input)), [sdk], ); diff --git a/apps/web-tools/trustchain/components/AppGetMembers.tsx b/apps/web-tools/trustchain/components/AppGetMembers.tsx index 5765e2b4e956..2731a217df8f 100644 --- a/apps/web-tools/trustchain/components/AppGetMembers.tsx +++ b/apps/web-tools/trustchain/components/AppGetMembers.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from "react"; import { JWT, Trustchain, TrustchainMember } from "@ledgerhq/trustchain/types"; import { Actionable } from "./Actionable"; -import { useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; export function AppGetMembers({ jwt, @@ -14,7 +14,7 @@ export function AppGetMembers({ members: TrustchainMember[] | null; setMembers: (members: TrustchainMember[] | null) => void; }) { - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const action = useCallback( (jwt: JWT, trustchain: Trustchain) => sdk.getMembers(jwt, trustchain), diff --git a/apps/web-tools/trustchain/components/AppGetOrCreateTrustchain.tsx b/apps/web-tools/trustchain/components/AppGetOrCreateTrustchain.tsx index cef6d2284079..adf37d3c60eb 100644 --- a/apps/web-tools/trustchain/components/AppGetOrCreateTrustchain.tsx +++ b/apps/web-tools/trustchain/components/AppGetOrCreateTrustchain.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from "react"; import { JWT, MemberCredentials, Trustchain } from "@ledgerhq/trustchain/types"; import { Actionable } from "./Actionable"; -import { useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; import { runWithDevice } from "../device"; export function AppGetOrCreateTrustchain({ @@ -17,7 +17,7 @@ export function AppGetOrCreateTrustchain({ setTrustchain: (trustchain: Trustchain | null) => void; setDeviceJWT: (deviceJWT: JWT | null) => void; }) { - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const action = useCallback( (deviceJWT: JWT, memberCredentials: MemberCredentials) => diff --git a/apps/web-tools/trustchain/components/AppInitLiveCredentials.tsx b/apps/web-tools/trustchain/components/AppInitLiveCredentials.tsx index ba0f968003f1..a4554d7fcf86 100644 --- a/apps/web-tools/trustchain/components/AppInitLiveCredentials.tsx +++ b/apps/web-tools/trustchain/components/AppInitLiveCredentials.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from "react"; import { MemberCredentials } from "@ledgerhq/trustchain/types"; import { Actionable } from "./Actionable"; -import { useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; export function AppInitLiveCredentials({ memberCredentials, @@ -10,7 +10,7 @@ export function AppInitLiveCredentials({ memberCredentials: MemberCredentials | null; setMemberCredentials: (memberCredentials: MemberCredentials | null) => void; }) { - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const action = useCallback(() => sdk.initMemberCredentials(), [sdk]); const valueDisplay = useCallback( diff --git a/apps/web-tools/trustchain/components/AppMemberRow.tsx b/apps/web-tools/trustchain/components/AppMemberRow.tsx index 17241db24b11..569b0e311a46 100644 --- a/apps/web-tools/trustchain/components/AppMemberRow.tsx +++ b/apps/web-tools/trustchain/components/AppMemberRow.tsx @@ -2,7 +2,7 @@ import React, { useCallback } from "react"; import { JWT, MemberCredentials, Trustchain, TrustchainMember } from "@ledgerhq/trustchain/types"; import { Actionable } from "./Actionable"; import { DisplayName } from "./IdentityManager"; -import { useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; import { runWithDevice } from "../device"; export function AppMemberRow({ @@ -22,7 +22,7 @@ export function AppMemberRow({ setDeviceJWT: (deviceJWT: JWT | null) => void; setMembers: (members: TrustchainMember[] | null) => void; }) { - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const action = useCallback( (deviceJWT: JWT, trustchain: Trustchain, memberCredentials: MemberCredentials) => diff --git a/apps/web-tools/trustchain/components/AppQRCodeHost.tsx b/apps/web-tools/trustchain/components/AppQRCodeHost.tsx index 12fd3204ce83..389b2e1a8fad 100644 --- a/apps/web-tools/trustchain/components/AppQRCodeHost.tsx +++ b/apps/web-tools/trustchain/components/AppQRCodeHost.tsx @@ -4,7 +4,7 @@ import { InvalidDigitsError } from "@ledgerhq/trustchain/errors"; import { MemberCredentials, Trustchain } from "@ledgerhq/trustchain/types"; import { RenderActionable } from "./Actionable"; import QRCode from "./QRCode"; -import { useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; export function AppQRCodeHost({ trustchain, @@ -13,7 +13,7 @@ export function AppQRCodeHost({ trustchain: Trustchain | null; memberCredentials: MemberCredentials | null; }) { - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const [error, setError] = useState(null); const [url, setUrl] = useState(null); const [digits, setDigits] = useState(null); diff --git a/apps/web-tools/trustchain/components/AppRestoreTrustchain.tsx b/apps/web-tools/trustchain/components/AppRestoreTrustchain.tsx index 67eb9a9e4694..d46526de2b29 100644 --- a/apps/web-tools/trustchain/components/AppRestoreTrustchain.tsx +++ b/apps/web-tools/trustchain/components/AppRestoreTrustchain.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from "react"; import { JWT, MemberCredentials, Trustchain } from "@ledgerhq/trustchain/types"; import { Actionable } from "./Actionable"; -import { useSDK } from "../context"; +import { useTrustchainSDK } from "../context"; export function AppRestoreTrustchain({ jwt, @@ -14,7 +14,7 @@ export function AppRestoreTrustchain({ trustchain: Trustchain | null; setTrustchain: (trustchain: Trustchain | null) => void; }) { - const sdk = useSDK(); + const sdk = useTrustchainSDK(); const action = useCallback( (jwt: JWT, trustchainId: string, memberCredentials: MemberCredentials) => sdk.restoreTrustchain(jwt, trustchainId, memberCredentials), diff --git a/apps/web-tools/trustchain/components/AppSetCloudSyncAPIEnv.tsx b/apps/web-tools/trustchain/components/AppSetCloudSyncAPIEnv.tsx new file mode 100644 index 000000000000..ad26fa9fe67a --- /dev/null +++ b/apps/web-tools/trustchain/components/AppSetCloudSyncAPIEnv.tsx @@ -0,0 +1,23 @@ +import React, { useCallback, useState } from "react"; +import { setEnv, getEnvDefault } from "@ledgerhq/live-env"; +import { Actionable } from "./Actionable"; +import useEnv from "../useEnv"; +import { Input } from "./Input"; + +export function AppSetCloudSyncAPIEnv() { + const env = useEnv("WALLET_SYNC_API"); + const [localValue, setLocalValue] = useState(env); + const action = useCallback(() => Promise.resolve(localValue), [localValue]); + return ( + setEnv("WALLET_SYNC_API", v || getEnvDefault("WALLET_SYNC_API"))} + valueDisplay={() => ( + setLocalValue(e.target.value)} /> + )} + /> + ); +} diff --git a/apps/web-tools/trustchain/components/AppWalletSync.tsx b/apps/web-tools/trustchain/components/AppWalletSync.tsx new file mode 100644 index 000000000000..ee55715d2dc2 --- /dev/null +++ b/apps/web-tools/trustchain/components/AppWalletSync.tsx @@ -0,0 +1,146 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { JWT, MemberCredentials, Trustchain } from "@ledgerhq/trustchain/types"; +import { useTrustchainSDK } from "../context"; +import { + WalletSyncSDK, + AccountsData, + accountsSchema, + UpdateEvent, +} from "@ledgerhq/live-wallet/walletsync/index"; +import { genAccount } from "@ledgerhq/coin-framework/mocks/account"; +import { getDefaultAccountName } from "@ledgerhq/live-wallet/accountName"; +import { Actionable } from "./Actionable"; +import { JsonEditor } from "./JsonEditor"; + +export function AppWalletSync({ + trustchain, + memberCredentials, +}: { + trustchain: Trustchain; + memberCredentials: MemberCredentials; +}) { + const trustchainSdk = useTrustchainSDK(); + + const [version, setVersion] = useState(0); // TODO this would need some persistance + const [data, setData] = useState(null); + const [json, setJson] = useState(""); + const [error, setError] = useState(null); + + const onJsonEditorChange = useCallback((value: string) => { + setJson(value); + try { + if (!value.trim()) { + setData(null); + setError(null); + return; + } + const data = JSON.parse(value); + const validated = accountsSchema.parse(data); + setData(validated); + setError(null); + } catch (e) { + setError("Invalid data: " + String(e)); + } + }, []); + + const versionRef = useRef(version); + useEffect(() => { + versionRef.current = version; + }, [version]); + + const getCurrentVersion = useCallback(() => versionRef.current, []); + + 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": + setVersion(event.version); + setData(event.data); + setJson(event.data ? JSON.stringify(event.data, null, 2) : ""); + break; + case "pushed-data": + setVersion(event.version); + break; + case "deleted-data": + setVersion(0); + setJson(""); + setData(null); + break; + } + }, []); + + const walletSyncSdk = useMemo(() => { + return new WalletSyncSDK({ + trustchainSdk, + getCurrentVersion, + saveNewUpdate, + }); + }, [trustchainSdk, getCurrentVersion, saveNewUpdate]); + + const onPull = useCallback(async () => { + const jwt = await trustchainSdk.auth(trustchain, memberCredentials); + await walletSyncSdk.pull(jwt, trustchain); + }, [trustchainSdk, trustchain, memberCredentials, walletSyncSdk]); + + const onPush = useCallback(async () => { + if (!data) return; + const jwt = await trustchainSdk.auth(trustchain, memberCredentials); + await walletSyncSdk.push(jwt, trustchain, data); + }, [trustchainSdk, trustchain, memberCredentials, walletSyncSdk, data]); + + const onGenRandomAccountData = useCallback(() => { + const names: Record = {}; + const accounts = Array(Math.floor(5 * Math.random())) + .fill(0) + .map(() => { + const account = genAccount(Math.random().toString()); + let name = getDefaultAccountName(account); + if (Math.random() > 0.5) { + name = "Renamed " + name; + } + names[account.id] = name; + return { + id: account.id, + currencyId: account.currency.id, + index: account.index, + seedIdentifier: account.seedIdentifier, + derivationMode: account.derivationMode, + freshAddress: account.freshAddress, + }; + }); + const data = { accounts, names }; + // locally reset the editor + setData(data); + setJson(JSON.stringify(data, null, 2)); + }, [setData]); + + const onDestroy = useCallback(async () => { + const jwt = await trustchainSdk.auth(trustchain, memberCredentials); + await walletSyncSdk.destroy(jwt); + }, [trustchainSdk, trustchain, memberCredentials, walletSyncSdk]); + + return ( +
+
+ Version: {version} + + + + +
+ + {error ?
{error}
: null} +
+ ); +} diff --git a/apps/web-tools/trustchain/components/JsonEditor.tsx b/apps/web-tools/trustchain/components/JsonEditor.tsx new file mode 100644 index 000000000000..0f6c88a59156 --- /dev/null +++ b/apps/web-tools/trustchain/components/JsonEditor.tsx @@ -0,0 +1,46 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type ace from "brace"; + +export function JsonEditor({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const initialValueRef = useRef(value); + const editorRef = useRef(null); + + // on value changes and it's diff from editor, we force it + useEffect(() => { + const timeout = setTimeout(() => { + if (editorRef.current && editorRef.current.getValue() !== value) { + console.log("FORCE", value); + editorRef.current.setValue(value); + } + }, 100); + return () => { + clearTimeout(timeout); + }; + }, [value]); + + useEffect(() => { + if (typeof window === "undefined") return; + const ace = require("brace"); + require("brace/mode/json"); + require("brace/theme/github"); + const editor = ace.edit("editor"); + editor.setTheme("ace/theme/github"); + editor.getSession().setMode("ace/mode/json"); + editor.setValue(initialValueRef.current); + editorRef.current = editor; + editor.on("change", () => { + onChange(editor.getValue()); + }); + return () => { + editor.destroy(); + }; + }, [onChange]); + + return
; +} diff --git a/apps/web-tools/trustchain/context.ts b/apps/web-tools/trustchain/context.ts index 839550d044de..e72915631094 100644 --- a/apps/web-tools/trustchain/context.ts +++ b/apps/web-tools/trustchain/context.ts @@ -4,6 +4,8 @@ import { getSdk } from "@ledgerhq/trustchain/lib-es/index"; export const defaultContext = { applicationId: 16, name: "WebTools" }; -export const SDKContext = React.createContext(getSdk(false, defaultContext)); +export const TrustchainSDKContext = React.createContext( + getSdk(false, defaultContext), +); -export const useSDK = () => useContext(SDKContext); +export const useTrustchainSDK = () => useContext(TrustchainSDKContext); diff --git a/libs/env/src/env.ts b/libs/env/src/env.ts index caefdd0720c4..f1be4684874c 100644 --- a/libs/env/src/env.ts +++ b/libs/env/src/env.ts @@ -633,6 +633,11 @@ const envDefinitions = { parser: boolParser, desc: "is walletconnect enabled", }, + WALLET_SYNC_API: { + def: "https://cloud-sync-backend.api.aws.stg.ldg-tech.com", + parser: stringParser, + desc: "wallet sync api base url", + }, WITH_DEVICE_POLLING_DELAY: { def: 500, parser: floatParser, diff --git a/libs/live-wallet/.unimportedrc.json b/libs/live-wallet/.unimportedrc.json index b6afa383d8c0..a06b0f5ad390 100644 --- a/libs/live-wallet/.unimportedrc.json +++ b/libs/live-wallet/.unimportedrc.json @@ -1,5 +1,6 @@ { "entry": [ + "src/walletsync/index.ts", "src/liveqr/cross.ts", "src/liveqr/importAccounts.ts", "src/ordering.ts", diff --git a/libs/live-wallet/package.json b/libs/live-wallet/package.json index 660dd5db062f..f552021a7a02 100644 --- a/libs/live-wallet/package.json +++ b/libs/live-wallet/package.json @@ -21,22 +21,27 @@ "types": "lib/index.d.ts", "license": "Apache-2.0", "dependencies": { - "@ledgerhq/types-live": "workspace:*", - "@ledgerhq/types-cryptoassets": "workspace:*", "@ledgerhq/coin-framework": "workspace:*", + "@ledgerhq/compressjs": "github:LedgerHQ/compressjs#d9e8e4d994923e0ea76a32b97289bcccfe71b82e", + "@ledgerhq/cryptoassets": "workspace:*", + "@ledgerhq/devices": "workspace:*", "@ledgerhq/live-env": "workspace:*", + "@ledgerhq/live-network": "workspace:*", "@ledgerhq/live-promise": "workspace:*", "@ledgerhq/logs": "workspace:*", - "@ledgerhq/compressjs": "github:LedgerHQ/compressjs#d9e8e4d994923e0ea76a32b97289bcccfe71b82e", - "@ledgerhq/devices": "workspace:*", - "@ledgerhq/cryptoassets": "workspace:*", - "rxjs": "7", + "@ledgerhq/trustchain": "workspace:*", + "@ledgerhq/types-cryptoassets": "workspace:*", + "@ledgerhq/types-live": "workspace:*", + "base64-js": "1", + "bignumber.js": "9", + "fflate": "^0.8.2", "lodash": "4", - "bignumber.js": "9" + "rxjs": "7", + "zod": "^3.22.4" }, "devDependencies": { - "@types/lodash": "4", "@types/jest": "^29.5.10", + "@types/lodash": "4", "jest": "^29.7.0", "ts-jest": "^29.1.1" }, diff --git a/libs/live-wallet/src/walletsync/api.ts b/libs/live-wallet/src/walletsync/api.ts new file mode 100644 index 000000000000..a5ee9d76ce3b --- /dev/null +++ b/libs/live-wallet/src/walletsync/api.ts @@ -0,0 +1,99 @@ +import z from "zod"; +import network from "@ledgerhq/live-network"; +import { getEnv } from "@ledgerhq/live-env"; + +export type JWT = { + accessToken: string; +}; + +const schemaAtomicGetNoData = z.object({ + status: z.literal("no-data"), +}); +const schemaAtomicGetUpToDate = z.object({ + status: z.literal("up-to-date"), +}); +const schemaAtomicGetOutOfSync = z.object({ + status: z.literal("out-of-sync"), + version: z.number(), + payload: z.string(), + date: z.string(), + info: z.string().optional(), +}); +const schemaAtomicGetResponse = z.discriminatedUnion("status", [ + schemaAtomicGetNoData, + schemaAtomicGetUpToDate, + schemaAtomicGetOutOfSync, +]); +export type APISyncResponse = z.infer; + +const schemaAtomicPostUpdated = z.object({ + status: z.literal("updated"), +}); +const schemaAtomicPostOutOfSync = z.object({ + status: z.literal("out-of-sync"), + version: z.number(), + payload: z.string(), + date: z.string(), + info: z.string().optional(), +}); +const schemaAtomicPostResponse = z.discriminatedUnion("status", [ + schemaAtomicPostUpdated, + schemaAtomicPostOutOfSync, +]); + +export type APISyncUpdateResponse = z.infer; + +// Fetch data status from cloud +async function fetchDataStatus( + jwt: JWT, + datatype: string, + version?: number, +): Promise { + const { data } = await network({ + url: `${getEnv("WALLET_SYNC_API")}/atomic/v1/${datatype}`, + method: "GET", + headers: { + Authorization: `Bearer ${jwt.accessToken}`, + }, + params: version !== undefined ? { version } : {}, + }); + return schemaAtomicGetResponse.parse(data); +} + +// Upload new version of data to cloud +async function uploadData( + jwt: JWT, + datatype: string, + version: number, + payload: string, +): Promise { + const { data } = await network({ + url: `${getEnv("WALLET_SYNC_API")}/atomic/v1/${datatype}?version=${version}`, + method: "POST", + headers: { + Authorization: `Bearer ${jwt.accessToken}`, + "Content-Type": "application/json", + }, + data: { + payload, + }, + }); + return schemaAtomicPostResponse.parse(data); +} + +// Delete data from cloud +async function deleteData(jwt: JWT, datatype: string): Promise { + await network({ + url: `${getEnv("WALLET_SYNC_API")}/atomic/v1/${datatype}`, + method: "DELETE", + headers: { + Authorization: `Bearer ${jwt.accessToken}`, + }, + }); +} + +export default { + fetchDataStatus, + uploadData, + deleteData, +}; diff --git a/libs/live-wallet/src/walletsync/datatypes/accounts.ts b/libs/live-wallet/src/walletsync/datatypes/accounts.ts new file mode 100644 index 000000000000..d82918e99561 --- /dev/null +++ b/libs/live-wallet/src/walletsync/datatypes/accounts.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const accountDescriptorSchema = z.object({ + id: z.string(), + currencyId: z.string(), + freshAddress: z.string(), + seedIdentifier: z.string(), + derivationMode: z.string(), + index: z.number(), +}); +export type AccountDescriptor = z.infer; + +export const accountsDescriptorSchema = z.array(accountDescriptorSchema); + +export const schema = z.object({ + accounts: accountsDescriptorSchema, + names: z.record(z.string()), + // NB: append more fields here when we have more needs in the future, but NEVER break a type +}); + +export type Data = z.infer; diff --git a/libs/live-wallet/src/walletsync/index.ts b/libs/live-wallet/src/walletsync/index.ts new file mode 100644 index 000000000000..c4c0f5219974 --- /dev/null +++ b/libs/live-wallet/src/walletsync/index.ts @@ -0,0 +1,3 @@ +export * from "./sdk"; +export { schema as accountsSchema } from "./datatypes/accounts"; +export type { Data as AccountsData } from "./datatypes/accounts"; diff --git a/libs/live-wallet/src/walletsync/sdk.ts b/libs/live-wallet/src/walletsync/sdk.ts new file mode 100644 index 000000000000..21c14c0b565a --- /dev/null +++ b/libs/live-wallet/src/walletsync/sdk.ts @@ -0,0 +1,112 @@ +import { Trustchain, TrustchainSDK } from "@ledgerhq/trustchain/types"; +import { Data, schema } from "./datatypes/accounts"; +import api, { JWT } from "./api"; +import Base64 from "base64-js"; +import { compress, decompress } from "fflate"; + +export type UpdateEvent = + | { + type: "new-data"; + data: Data; + version: number; + } + | { + type: "pushed-data"; + version: number; + } + | { + type: "deleted-data"; + }; + +export class WalletSyncSDK { + trustchainSdk: TrustchainSDK; + getCurrentVersion: () => number | undefined; + saveNewUpdate: (updateEvent: UpdateEvent) => Promise; + + constructor({ + trustchainSdk, + getCurrentVersion, + saveNewUpdate, + }: { + trustchainSdk: TrustchainSDK; + /** + * returns the current version of the data, if available. + */ + getCurrentVersion: () => number | undefined; + /** + * apply the data over the accounts and we also save the version. + */ + saveNewUpdate: (event: UpdateEvent) => Promise; + }) { + this.trustchainSdk = trustchainSdk; + this.getCurrentVersion = getCurrentVersion; + this.saveNewUpdate = saveNewUpdate; + } + + async push(jwt: JWT, trustchain: Trustchain, data: Data): Promise { + const validated = schema.parse(data); + const json = JSON.stringify(validated); + const bytes = new TextEncoder().encode(json); + const compressed = await new Promise((resolve, reject) => + compress(bytes, (err, result) => (err ? reject(err) : resolve(result))), + ); + const encrypted = await this.trustchainSdk.encryptUserData(trustchain, compressed); + const base64 = Base64.fromByteArray(encrypted); + const version = (this.getCurrentVersion() || 0) + 1; + const response = await api.uploadData(jwt, "accounts", version, base64); + switch (response.status) { + case "updated": { + await this.saveNewUpdate({ + type: "pushed-data", + version, + }); + break; + } + case "out-of-sync": { + // WHAT TO DO? maybe we ignore because in this case we just wait for a pull? + console.warn("out-of-sync", response); + } + } + } + + async pull(jwt: JWT, trustchain: Trustchain): Promise { + const response = await api.fetchDataStatus(jwt, "accounts", this.getCurrentVersion()); + switch (response.status) { + case "no-data": { + // no data, nothing to do + break; + } + case "up-to-date": { + // already up to date + break; + } + case "out-of-sync": { + const decrypted = await this.trustchainSdk + .decryptUserData(trustchain, Base64.toByteArray(response.payload)) + .catch(e => { + // TODO if we fail to decrypt, it may mean we need to restore trustchain. and if it still fails and on specific error, we will have to eject. figure out how to integrate this in the pull lifecycle. + throw e; + }); + const decompressed = await new Promise((resolve, reject) => + decompress(decrypted, (err, result) => (err ? reject(err) : resolve(result))), + ); + const json = JSON.parse(new TextDecoder().decode(decompressed)); + const validated = schema.parse(json); + const version = response.version; + await this.saveNewUpdate({ + type: "new-data", + data: validated, + version, + }); + break; + } + } + } + + async destroy(jwt: JWT): Promise { + await api.deleteData(jwt, "accounts"); + await this.saveNewUpdate({ + type: "deleted-data", + }); + } +} diff --git a/libs/trustchain/src/mockSdk.ts b/libs/trustchain/src/mockSdk.ts index 4f5dd8cafbde..576a6e7cd791 100644 --- a/libs/trustchain/src/mockSdk.ts +++ b/libs/trustchain/src/mockSdk.ts @@ -144,14 +144,14 @@ class MockSDK implements TrustchainSDK { return Promise.resolve(); } - encryptUserData(trustchain: Trustchain, obj: object): Promise { + encryptUserData(trustchain: Trustchain, input: Uint8Array): Promise { assertTrustchain(trustchain); - return Promise.resolve(new TextEncoder().encode(JSON.stringify(obj))); + return Promise.resolve(input); } - decryptUserData(trustchain: Trustchain, data: Uint8Array): Promise { + decryptUserData(trustchain: Trustchain, data: Uint8Array): Promise { assertTrustchain(trustchain); - return Promise.resolve(JSON.parse(new TextDecoder().decode(data))); + return Promise.resolve(data); } } diff --git a/libs/trustchain/src/sdk.test.ts b/libs/trustchain/src/sdk.test.ts index 6e65c371c0ca..03830bce6a2b 100644 --- a/libs/trustchain/src/sdk.test.ts +++ b/libs/trustchain/src/sdk.test.ts @@ -3,10 +3,7 @@ import { getSdk } from "."; test("encryptUserData + decryptUserData", async () => { const sdk = getSdk(false, { applicationId: 16, name: "test" }); - const obj = { - foobar: 42, - toto: "tata", - }; + const obj = new Uint8Array([1, 2, 3, 4, 5]); const keypair = await crypto.randomKeypair(); const trustchain = { rootId: "", diff --git a/libs/trustchain/src/sdk.ts b/libs/trustchain/src/sdk.ts index 457d342bb868..ace334ff2f38 100644 --- a/libs/trustchain/src/sdk.ts +++ b/libs/trustchain/src/sdk.ts @@ -23,7 +23,6 @@ import { import Transport from "@ledgerhq/hw-transport"; import api from "./api"; import { KeyPair as CryptoKeyPair } from "@ledgerhq/hw-trustchain/Crypto"; -import { makeCipher } from "./wallet-sync-cipher"; import { log } from "@ledgerhq/logs"; export class SDK implements TrustchainSDK { @@ -291,15 +290,15 @@ export class SDK implements TrustchainSDK { await api.deleteTrustchain(jwt, trustchain.rootId); } - async encryptUserData(trustchain: Trustchain, obj: object): Promise { - const cipher = makeCipher(crypto.from_hex(trustchain.walletSyncEncryptionKey)); - const encrypted = await cipher.encrypt(obj); + async encryptUserData(trustchain: Trustchain, input: Uint8Array): Promise { + const key = crypto.from_hex(trustchain.walletSyncEncryptionKey); + const encrypted = await crypto.encryptUserData(key, input); return encrypted; } - async decryptUserData(trustchain: Trustchain, data: Uint8Array): Promise { - const cipher = makeCipher(crypto.from_hex(trustchain.walletSyncEncryptionKey)); - const decrypted = await cipher.decrypt(data); + async decryptUserData(trustchain: Trustchain, data: Uint8Array): Promise { + const key = crypto.from_hex(trustchain.walletSyncEncryptionKey); + const decrypted = await crypto.decryptUserData(key, data); return decrypted; } } diff --git a/libs/trustchain/src/types.ts b/libs/trustchain/src/types.ts index 0d0bbb09e971..a4070e8a3b78 100644 --- a/libs/trustchain/src/types.ts +++ b/libs/trustchain/src/types.ts @@ -163,7 +163,7 @@ export interface TrustchainSDK { /** * decrypt data with the trustchain encryption key */ - decryptUserData(trustchain: Trustchain, data: Uint8Array): Promise; + decryptUserData(trustchain: Trustchain, data: Uint8Array): Promise; } export interface TrustchainDeviceCallbacks { diff --git a/libs/trustchain/src/wallet-sync-cipher.ts b/libs/trustchain/src/wallet-sync-cipher.ts deleted file mode 100644 index e067d5faaa5b..000000000000 --- a/libs/trustchain/src/wallet-sync-cipher.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { crypto } from "@ledgerhq/hw-trustchain"; - -export type Cipher = { - encrypt: (obj: object) => Promise; - decrypt: (data: Uint8Array) => Promise; -}; - -export function makeCipher(commandStreamPrivateKey: Uint8Array): Cipher { - async function encrypt(obj: object): Promise { - const plaintext = JSON.stringify(obj); - const data = new TextEncoder().encode(plaintext); - const blob = await crypto.encryptUserData(commandStreamPrivateKey, data); - return blob; - } - - async function decrypt(blob: Uint8Array): Promise { - const plaintext = await crypto.decryptUserData(commandStreamPrivateKey, blob); - const text = new TextDecoder().decode(plaintext); - return JSON.parse(text); - } - - return { encrypt, decrypt }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c85aeca40596..e954fe3ab3b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1462,6 +1462,9 @@ importers: '@ledgerhq/live-env': specifier: workspace:^ version: link:../../libs/env + '@ledgerhq/live-wallet': + specifier: workspace:^ + version: link:../../libs/live-wallet '@ledgerhq/logs': specifier: workspace:^ version: link:../../libs/ledgerjs/packages/logs @@ -1477,6 +1480,9 @@ importers: bignumber.js: specifier: ^9.1.2 version: 9.1.2 + brace: + specifier: ^0.11.1 + version: 0.11.1 bufferutil: specifier: ^4.0.7 version: 4.0.8 @@ -5349,27 +5355,42 @@ importers: '@ledgerhq/live-env': specifier: workspace:* version: link:../env + '@ledgerhq/live-network': + specifier: workspace:* + version: link:../live-network '@ledgerhq/live-promise': specifier: workspace:* version: link:../promise '@ledgerhq/logs': specifier: workspace:* version: link:../ledgerjs/packages/logs + '@ledgerhq/trustchain': + specifier: workspace:* + version: link:../trustchain '@ledgerhq/types-cryptoassets': specifier: workspace:* version: link:../ledgerjs/packages/types-cryptoassets '@ledgerhq/types-live': specifier: workspace:* version: link:../ledgerjs/packages/types-live + base64-js: + specifier: '1' + version: 1.5.1 bignumber.js: specifier: '9' version: 9.1.2 + fflate: + specifier: ^0.8.2 + version: 0.8.2 lodash: specifier: '4' version: 4.17.21 rxjs: specifier: '7' version: 7.8.1 + zod: + specifier: ^3.22.4 + version: 3.22.4 devDependencies: '@types/jest': specifier: ^29.5.10 @@ -24934,7 +24955,6 @@ packages: typescript: 5.2.2 transitivePeerDependencies: - supports-color - dev: false /@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.4.3): resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} @@ -24955,6 +24975,7 @@ packages: typescript: 5.4.3 transitivePeerDependencies: - supports-color + dev: true /@typescript-eslint/scope-manager@5.62.0: resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} @@ -25079,7 +25100,6 @@ packages: typescript: 5.2.2 transitivePeerDependencies: - supports-color - dev: false /@typescript-eslint/typescript-estree@6.21.0(typescript@5.4.3): resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} @@ -25101,6 +25121,7 @@ packages: typescript: 5.4.3 transitivePeerDependencies: - supports-color + dev: true /@typescript-eslint/utils@5.62.0(eslint@8.57.0)(typescript@5.4.3): resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} @@ -27453,6 +27474,10 @@ packages: dependencies: balanced-match: 1.0.2 + /brace@0.11.1: + resolution: {integrity: sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q==} + dev: false + /braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -31862,6 +31887,7 @@ packages: - eslint-import-resolver-node - eslint-import-resolver-webpack - supports-color + dev: true /eslint-module-utils@2.8.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.0): resolution: {integrity: sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==} @@ -31913,11 +31939,11 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.2.2) debug: 3.2.7 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-plugin-import@2.29.1)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0) transitivePeerDependencies: - supports-color @@ -31977,6 +32003,7 @@ packages: eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0)(eslint-plugin-import@2.29.1)(eslint@8.57.0) transitivePeerDependencies: - supports-color + dev: true /eslint-plugin-detox@1.0.0: resolution: {integrity: sha512-Dd+Cwyap5IO9DBKXOKrQTE1RQk9hvSSi+qsS1cMVPZY37mojz2PvriEOfGhKj5XN1G14lJ8TArf+6Y+Np2ZsoQ==} @@ -32061,7 +32088,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.4.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.2.2) array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 array.prototype.flat: 1.3.2 @@ -33925,6 +33952,10 @@ packages: resolution: {integrity: sha512-3yurQZ2hD9VISAhJJP9bpYFNQrHHBXE2JxxjY5aLEcDi46RmAzJE2OC9FAde0yis5ElW0jTTzs0zfg/Cca4XqQ==} dev: true + /fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + dev: false + /figures@1.7.0: resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==} engines: {node: '>=0.10.0'} @@ -51218,7 +51249,6 @@ packages: typescript: '>=4.2.0' dependencies: typescript: 5.2.2 - dev: false /ts-api-utils@1.3.0(typescript@5.4.3): resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} @@ -51227,6 +51257,7 @@ packages: typescript: '>=4.2.0' dependencies: typescript: 5.4.3 + dev: true /ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} @@ -51392,7 +51423,7 @@ packages: dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(ts-node@10.9.2) + jest: 29.7.0 jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -52095,7 +52126,6 @@ packages: resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} hasBin: true - dev: false /typescript@5.4.3: resolution: {integrity: sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==}