Skip to content

Commit

Permalink
chore: introduce hooks to be able to restoreTrustchain
Browse files Browse the repository at this point in the history
  • Loading branch information
gre committed Jul 9, 2024
1 parent d5d7212 commit d8b8aa8
Show file tree
Hide file tree
Showing 14 changed files with 445 additions and 179 deletions.
19 changes: 17 additions & 2 deletions apps/web-tools/trustchain/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import dynamic from "next/dynamic";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import styled from "styled-components";
import { Tooltip } from "react-tooltip";
import { MemberCredentials, Trustchain, TrustchainMember } from "@ledgerhq/trustchain/types";
Expand Down Expand Up @@ -28,6 +28,7 @@ import { AppSetCloudSyncAPIEnv } from "./AppSetCloudSyncAPIEnv";
import { DeviceInteractionLayer } from "./DeviceInteractionLayer";
import { Account } from "@ledgerhq/types-live";
import { WalletState, initialState as walletInitialState } from "@ledgerhq/live-wallet/store";
import { trustchainLifecycle } from "@ledgerhq/live-wallet/walletsync/index";
import { Loading } from "./Loading";

const Container = styled.div`
Expand Down Expand Up @@ -91,8 +92,21 @@ const App = () => {

const mockEnv = useEnv("MOCK");

const wsStateRef = useRef(state.walletState.wsState);
useEffect(() => {
wsStateRef.current = state.walletState.wsState;
}, [state.walletState.wsState]);

const lifecycle = useMemo(
() =>
trustchainLifecycle({
getCurrentWSState: () => wsStateRef.current,
}),
[],
);

const sdk = useMemo(
() => getSdk(!!mockEnv, context),
() => getSdk(!!mockEnv, context, lifecycle),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
mockEnv,
Expand Down Expand Up @@ -260,6 +274,7 @@ const App = () => {
{trustchain && memberCredentials ? (
<AppWalletSync
trustchain={trustchain}
setTrustchain={setTrustchain}
memberCredentials={memberCredentials}
version={version}
setVersion={setWssdkHandledVersion}
Expand Down
3 changes: 2 additions & 1 deletion apps/web-tools/trustchain/components/AppAccountsSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
walletSyncUpdate,
} from "@ledgerhq/live-wallet/store";
import walletsync, {
liveSlug,
DistantState,
walletSyncWatchLoop,
} from "@ledgerhq/live-wallet/walletsync/index";
Expand Down Expand Up @@ -159,7 +160,7 @@ export default function AppAccountsSync({
const walletSyncSdk = useMemo(
() =>
new CloudSyncSDK({
slug: "live",
slug: liveSlug,
schema: walletsync.schema,
trustchainSdk,
getCurrentVersion,
Expand Down
28 changes: 25 additions & 3 deletions apps/web-tools/trustchain/components/AppCloudSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { JWT, MemberCredentials, Trustchain } from "@ledgerhq/trustchain/types";
import { useTrustchainSDK } from "../context";
import { CloudSyncSDK, UpdateEvent } from "@ledgerhq/live-wallet/cloudsync/index";
import walletsync, { DistantState as LiveData } from "@ledgerhq/live-wallet/walletsync/index";
import walletsync, {
DistantState as LiveData,
liveSlug,
} 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";
import { TrustchainEjected } from "@ledgerhq/trustchain/lib-es/errors";

const liveSchema = walletsync.schema;

export function AppWalletSync({
trustchain,
setTrustchain,
memberCredentials,
version,
setVersion,
Expand All @@ -20,7 +25,9 @@ export function AppWalletSync({
takeControl,
}: {
trustchain: Trustchain;
setTrustchain: (t: Trustchain | null) => void;
memberCredentials: MemberCredentials;
// TODO set both version AND data for the hooks mechanism to work
version: number;
setVersion: (n: number) => void;
forceReadOnlyData?: LiveData | null;
Expand Down Expand Up @@ -85,15 +92,30 @@ export function AppWalletSync({
[setVersion, setData, setJson],
);

const onTrustchainRefreshNeeded = useCallback(
async (trustchain: Trustchain) => {
try {
const newTrustchain = await trustchainSdk.restoreTrustchain(trustchain, memberCredentials);
setTrustchain(newTrustchain);
} catch (e) {
if (e instanceof TrustchainEjected) {
setTrustchain(null);
}
}
},
[trustchainSdk, setTrustchain, memberCredentials],
);

const walletSyncSdk = useMemo(() => {
return new CloudSyncSDK({
slug: "live",
slug: liveSlug,
schema: walletsync.schema,
trustchainSdk,
getCurrentVersion,
saveNewUpdate,
onTrustchainRefreshNeeded,
});
}, [trustchainSdk, getCurrentVersion, saveNewUpdate]);
}, [trustchainSdk, getCurrentVersion, saveNewUpdate, onTrustchainRefreshNeeded]);

const onPull = useCallback(async () => {
await walletSyncSdk.pull(trustchain, memberCredentials);
Expand Down
6 changes: 6 additions & 0 deletions libs/live-wallet/src/cloudsync/__tests__/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ describe("CloudSyncSDK basics", () => {

const getCurrentVersion = () => version;

const onTrustchainRefreshNeeded = () => Promise.resolve();

const saveNewUpdate = (up: UpdateEvent<Data>) => {
switch (up.type) {
case "new-data":
Expand All @@ -126,6 +128,7 @@ describe("CloudSyncSDK basics", () => {
trustchainSdk,
getCurrentVersion,
saveNewUpdate,
onTrustchainRefreshNeeded,
});
});

Expand Down Expand Up @@ -203,6 +206,8 @@ describe("CloudSyncSDK basics", () => {

const getCurrentVersion = () => version2;

const onTrustchainRefreshNeeded = () => Promise.resolve();

const saveNewUpdate = (up: UpdateEvent<Data>) => {
switch (up.type) {
case "new-data":
Expand All @@ -226,6 +231,7 @@ describe("CloudSyncSDK basics", () => {
trustchainSdk,
getCurrentVersion,
saveNewUpdate,
onTrustchainRefreshNeeded,
});

await sdk.pull(trustchain, creds);
Expand Down
38 changes: 38 additions & 0 deletions libs/live-wallet/src/cloudsync/cipher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Base64 from "base64-js";
import { compress, decompress } from "fflate";
import { TrustchainSDK, Trustchain } from "@ledgerhq/trustchain/types";

export type Cipher<Data> = {
decrypt: (trustchain: Trustchain, base64payload: string) => Promise<Data>;
encrypt: (trustchain: Trustchain, data: Data) => Promise<string>;
};

/**
* Create a cipher that can encrypt/decrypt data using a trustchain for cloudsync
*/
export function makeCipher<Data>(trustchainSdk: TrustchainSDK): Cipher<Data> {
return {
decrypt: async (trustchain: Trustchain, base64payload: string): Promise<Data> => {
const decrypted = await trustchainSdk.decryptUserData(
trustchain,
Base64.toByteArray(base64payload),
);
const decompressed = await new Promise<Uint8Array>((resolve, reject) =>
decompress(decrypted, (err, result) => (err ? reject(err) : resolve(result))),
);
const json = JSON.parse(new TextDecoder().decode(decompressed));
return json;
},

encrypt: async (trustchain: Trustchain, data: Data): Promise<string> => {
const json = JSON.stringify(data);
const bytes = new TextEncoder().encode(json);
const compressed = await new Promise<Uint8Array>((resolve, reject) =>
compress(bytes, (err, result) => (err ? reject(err) : resolve(result))),
);
const encrypted = await trustchainSdk.encryptUserData(trustchain, compressed);
const base64 = Base64.fromByteArray(encrypted);
return base64;
},
};
}
56 changes: 23 additions & 33 deletions libs/live-wallet/src/cloudsync/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { MemberCredentials, Trustchain, TrustchainSDK } from "@ledgerhq/trustchain/types";
import api, { JWT } from "./api";
import Base64 from "base64-js";
import { compress, decompress } from "fflate";
import { Observable } from "rxjs";
import { z, ZodType } from "zod";
import { Cipher, makeCipher } from "./cipher";

export type UpdateEvent<Data> =
| {
Expand All @@ -21,18 +20,21 @@ export type UpdateEvent<Data> =
};

export class CloudSyncSDK<Schema extends ZodType, Data = z.infer<Schema>> {
slug: string;
schema: Schema;
trustchainSdk: TrustchainSDK;
getCurrentVersion: () => number | undefined;
saveNewUpdate: (updateEvent: UpdateEvent<Data>) => Promise<void>;
private slug: string;
private schema: Schema;
private trustchainSdk: TrustchainSDK;
private getCurrentVersion: () => number | undefined;
private saveNewUpdate: (updateEvent: UpdateEvent<Data>) => Promise<void>;
private cipher: Cipher<Data>;
private onTrustchainRefreshNeeded: (trustchain: Trustchain) => Promise<void>;

constructor({
slug,
schema,
trustchainSdk,
getCurrentVersion,
saveNewUpdate,
onTrustchainRefreshNeeded,
}: {
/**
* slug used with cloud sync API ((example "live")
Expand All @@ -55,13 +57,19 @@ export class CloudSyncSDK<Schema extends ZodType, Data = z.infer<Schema>> {
* All the reconciliation and async save can be performed at this step in order to guarantee atomicity of the operations.
*/
saveNewUpdate: (event: UpdateEvent<Data>) => Promise<void>;
/**
* called with the trustchain is possibly outdated.
* a typical implementation is to call trustchainSdk.restoreTrustchain and update trustchain object.
*/
onTrustchainRefreshNeeded: (trustchain: Trustchain) => Promise<void>;
}) {
this.slug = slug;
this.schema = schema;
this.trustchainSdk = trustchainSdk;
this.getCurrentVersion = getCurrentVersion;
this.saveNewUpdate = saveNewUpdate;

this.cipher = makeCipher(trustchainSdk);
this.onTrustchainRefreshNeeded = onTrustchainRefreshNeeded;
this.push = this._decorateMethod("push", this.push);
this.pull = this._decorateMethod("pull", this.pull);
this.destroy = this._decorateMethod("destroy", this.destroy);
Expand All @@ -78,13 +86,7 @@ export class CloudSyncSDK<Schema extends ZodType, Data = z.infer<Schema>> {
): Promise<void> {
this.schema.parse(data); // validate against the schema, throws if it doesn't parse
const validated = data; // IMPORTANT: we intentionally don't take validated out of parse() because we need to keep the possible extra field that we don't handle yet and that need to be preserved on the distant data
const json = JSON.stringify(validated);
const bytes = new TextEncoder().encode(json);
const compressed = await new Promise<Uint8Array>((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 base64 = await this.cipher.encrypt(trustchain, validated);
const version = (this.getCurrentVersion() || 0) + 1;
const response = await this.trustchainSdk.withAuth(trustchain, memberCredentials, jwt =>
api.uploadData(jwt, this.slug, version, base64),
Expand Down Expand Up @@ -116,32 +118,22 @@ export class CloudSyncSDK<Schema extends ZodType, Data = z.infer<Schema>> {
switch (response.status) {
case "no-data": {
// no data, nothing to do
const version = this.getCurrentVersion();
if (version) {
await this.onTrustchainRefreshNeeded(trustchain);
}
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.
// or do we "let it fail" and handle it more globally on app side? TBD
throw e;
});
const decompressed = await new Promise<Uint8Array>((resolve, reject) =>
decompress(decrypted, (err, result) => (err ? reject(err) : resolve(result))),
);
const json = JSON.parse(new TextDecoder().decode(decompressed));
const json = await this.cipher.decrypt(trustchain, response.payload);
this.schema.parse(json); // validate against the schema, throws if it doesn't parse
const validated = json; // IMPORTANT: we intentionally don't take validated out of parse() because we need to keep the possible extra field that we don't handle yet and that need to be preserved on the distant data
const version = response.version;
await this.saveNewUpdate({
type: "new-data",
data: validated,
version,
});
await this.saveNewUpdate({ type: "new-data", data: validated, version });
break;
}
}
Expand All @@ -164,11 +156,9 @@ export class CloudSyncSDK<Schema extends ZodType, Data = z.infer<Schema>> {
): (...args: A) => Promise<R> {
return async (...args) => {
const { _lock } = this;

if (_lock) {
return Promise.reject(new Error("CloudSyncSDK locked (" + this._lock + ")"));
}

try {
this._lock = methodName;
return await f.apply(this, args);
Expand Down
Loading

0 comments on commit d8b8aa8

Please sign in to comment.