diff --git a/.changeset/blue-pandas-wash.md b/.changeset/blue-pandas-wash.md new file mode 100644 index 000000000..f27aa75b3 --- /dev/null +++ b/.changeset/blue-pandas-wash.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement SetPluginCommand diff --git a/.changeset/brave-squids-obey.md b/.changeset/brave-squids-obey.md new file mode 100644 index 000000000..6480cb3d8 --- /dev/null +++ b/.changeset/brave-squids-obey.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement SendEIP712FilteringCommand diff --git a/.changeset/brown-lizards-juggle.md b/.changeset/brown-lizards-juggle.md new file mode 100644 index 000000000..22151dcd9 --- /dev/null +++ b/.changeset/brown-lizards-juggle.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement SignEIP712Command diff --git a/.changeset/chatty-bats-sleep.md b/.changeset/chatty-bats-sleep.md new file mode 100644 index 000000000..086d7d8a4 --- /dev/null +++ b/.changeset/chatty-bats-sleep.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement ProvideTokenInformationCommand diff --git a/.changeset/chatty-waves-live.md b/.changeset/chatty-waves-live.md new file mode 100644 index 000000000..13974c707 --- /dev/null +++ b/.changeset/chatty-waves-live.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-sdk-sample": patch +--- + +Add ListAppsWithMetadataDeviceAction in sample app diff --git a/.changeset/gentle-zebras-raise.md b/.changeset/gentle-zebras-raise.md new file mode 100644 index 000000000..a01029723 --- /dev/null +++ b/.changeset/gentle-zebras-raise.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Add transaction serialized to transaction mapper result diff --git a/.changeset/good-oranges-move.md b/.changeset/good-oranges-move.md new file mode 100644 index 000000000..0818b4949 --- /dev/null +++ b/.changeset/good-oranges-move.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement ProvideNFTInformationCommand diff --git a/.changeset/happy-lions-eat.md b/.changeset/happy-lions-eat.md new file mode 100644 index 000000000..282f441d6 --- /dev/null +++ b/.changeset/happy-lions-eat.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-sdk-core": patch +--- + +Improve code visibility diff --git a/.changeset/late-flies-smile.md b/.changeset/late-flies-smile.md new file mode 100644 index 000000000..f9f58524e --- /dev/null +++ b/.changeset/late-flies-smile.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement SendEIP712StructImplemCommand diff --git a/.changeset/ninety-shirts-beg.md b/.changeset/ninety-shirts-beg.md new file mode 100644 index 000000000..d27e80481 --- /dev/null +++ b/.changeset/ninety-shirts-beg.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-sdk-core": minor +--- + +Add ManagerApi service to core diff --git a/.changeset/old-ads-deny.md b/.changeset/old-ads-deny.md new file mode 100644 index 000000000..485a232cd --- /dev/null +++ b/.changeset/old-ads-deny.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/context-module": minor +--- + +Bump ethers to v6.13.2 diff --git a/.changeset/old-cups-cheat.md b/.changeset/old-cups-cheat.md new file mode 100644 index 000000000..f73b644d9 --- /dev/null +++ b/.changeset/old-cups-cheat.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Add Set External Plugin command diff --git a/.changeset/proud-flies-type.md b/.changeset/proud-flies-type.md new file mode 100644 index 000000000..18ca07f8a --- /dev/null +++ b/.changeset/proud-flies-type.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement ProvideDomainNameCommand diff --git a/.changeset/proud-turtles-tease.md b/.changeset/proud-turtles-tease.md new file mode 100644 index 000000000..974f41c7c --- /dev/null +++ b/.changeset/proud-turtles-tease.md @@ -0,0 +1,6 @@ +--- +"@ledgerhq/device-sdk-core": patch +"@ledgerhq/device-sdk-sample": patch +--- + +Add support of Ledger Flex diff --git a/.changeset/selfish-months-decide.md b/.changeset/selfish-months-decide.md new file mode 100644 index 000000000..c9211bf08 --- /dev/null +++ b/.changeset/selfish-months-decide.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Bump ethers to v6.13.2 diff --git a/.changeset/seven-beans-poke.md b/.changeset/seven-beans-poke.md new file mode 100644 index 000000000..beb73471a --- /dev/null +++ b/.changeset/seven-beans-poke.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Add SignPersonalMessage command diff --git a/.changeset/sixty-hotels-cheat.md b/.changeset/sixty-hotels-cheat.md new file mode 100644 index 000000000..3389a575a --- /dev/null +++ b/.changeset/sixty-hotels-cheat.md @@ -0,0 +1,6 @@ +--- +"@ledgerhq/keyring-eth": patch +"@ledgerhq/device-sdk-core": patch +--- + +DSDK-420 Implement the EIP712 TypedData parser service diff --git a/.changeset/strong-terms-brake.md b/.changeset/strong-terms-brake.md new file mode 100644 index 000000000..dc4ec9b99 --- /dev/null +++ b/.changeset/strong-terms-brake.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/context-module": patch +--- + +Update readme file diff --git a/.changeset/thick-zoos-travel.md b/.changeset/thick-zoos-travel.md new file mode 100644 index 000000000..4faf47363 --- /dev/null +++ b/.changeset/thick-zoos-travel.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement SendEIP712StructDefinitionCommand diff --git a/.changeset/unlucky-pears-sort.md b/.changeset/unlucky-pears-sort.md new file mode 100644 index 000000000..cd8f286de --- /dev/null +++ b/.changeset/unlucky-pears-sort.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-sdk-core": minor +--- + +Add ListAppsWithMetadata device action diff --git a/.changeset/witty-dancers-boil.md b/.changeset/witty-dancers-boil.md new file mode 100644 index 000000000..8f9656186 --- /dev/null +++ b/.changeset/witty-dancers-boil.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement SignTransactionCommand diff --git a/.changeset/witty-plums-search.md b/.changeset/witty-plums-search.md new file mode 100644 index 000000000..28176e306 --- /dev/null +++ b/.changeset/witty-plums-search.md @@ -0,0 +1,7 @@ +--- +"@ledgerhq/context-module": patch +"@ledgerhq/keyring-eth": patch +"@ledgerhq/device-sdk-core": patch +--- + +add HexaString to handle `0x${string}` type diff --git a/apps/sample/package.json b/apps/sample/package.json index 63072f80f..144a26a9c 100644 --- a/apps/sample/package.json +++ b/apps/sample/package.json @@ -15,8 +15,8 @@ "dependencies": { "@ledgerhq/device-sdk-core": "workspace:*", "@ledgerhq/keyring-eth": "workspace:*", - "@ledgerhq/react-ui": "^0.15.1", - "@sentry/nextjs": "^8.13.0", + "@ledgerhq/react-ui": "^0.15.3", + "@sentry/nextjs": "^8.20.0", "next": "14.2.4", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -28,7 +28,7 @@ "@ledgerhq/tsconfig-dsdk": "workspace:*", "@types/react": "^18.3.3", "@types/styled-components": "^5.1.25", - "autoprefixer": "^10.4.19", + "autoprefixer": "^10.4.20", "globals": "15.8.0", "postcss": "^8.4.38", "typescript": "5.4.5" diff --git a/apps/sample/src/components/Device/index.tsx b/apps/sample/src/components/Device/index.tsx index 3a2d8c587..6ce188217 100644 --- a/apps/sample/src/components/Device/index.tsx +++ b/apps/sample/src/components/Device/index.tsx @@ -43,6 +43,17 @@ type DeviceProps = { onDisconnect: () => Promise; }; +function getIconComponent(model: DeviceModelId) { + switch (model) { + case DeviceModelId.STAX: + return Icons.Stax; + case DeviceModelId.FLEX: + return Icons.Flex; + default: + return Icons.Nano; + } +} + export const Device: React.FC = ({ name, type, @@ -51,14 +62,11 @@ export const Device: React.FC = ({ sessionId, }) => { const sessionState = useDeviceSessionState(sessionId); + const IconComponent = getIconComponent(model); return ( - {model === DeviceModelId.STAX ? ( - - ) : ( - - )} + {name} diff --git a/apps/sample/src/components/DeviceActionsView/index.tsx b/apps/sample/src/components/DeviceActionsView/index.tsx index 87f2fe508..798ce20d6 100644 --- a/apps/sample/src/components/DeviceActionsView/index.tsx +++ b/apps/sample/src/components/DeviceActionsView/index.tsx @@ -24,6 +24,11 @@ import { ListAppsDAOutput, ListAppsDAError, ListAppsDAIntermediateValue, + ListAppsWithMetadataDeviceAction, + ListAppsWithMetadataDAError, + ListAppsWithMetadataDAInput, + ListAppsWithMetadataDAIntermediateValue, + ListAppsWithMetadataDAOutput, } from "@ledgerhq/device-sdk-core"; const UNLOCK_TIMEOUT = 60 * 1000; // 1 minute @@ -118,6 +123,27 @@ export const DeviceActionsView: React.FC<{ sessionId: string }> = ({ ListAppsDAError, ListAppsDAIntermediateValue >, + { + title: "List apps with metadata", + description: + "List all applications installed on the device with additional metadata", + executeDeviceAction: (_, inspect) => { + const deviceAction = new ListAppsWithMetadataDeviceAction({ + input: { unlockTimeout: UNLOCK_TIMEOUT }, + inspect, + }); + return sdk.executeDeviceAction({ + sessionId: selectedSessionId, + deviceAction, + }); + }, + initialValues: { unlockTimeout: UNLOCK_TIMEOUT }, + } satisfies DeviceActionProps< + ListAppsWithMetadataDAOutput, + ListAppsWithMetadataDAInput, + ListAppsWithMetadataDAError, + ListAppsWithMetadataDAIntermediateValue + >, ], [], ); diff --git a/package.json b/package.json index e5b4fd58d..756fb84e6 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.7", "@types/jest": "^29.5.12", - "@types/node": "^20.14.11", + "@types/node": "^22.4.0", "concurrently": "^8.2.2", "danger": "^12.3.3", "eslint": "9.8.0", @@ -44,7 +44,7 @@ "tsc-alias": "^1.8.10", "turbo": "^2.0.9", "typescript": "^5.5.3", - "zx": "^8.1.2" + "zx": "^8.1.4" }, "engines": { "node": ">=18" diff --git a/packages/config/jest/jest-preset.js b/packages/config/jest/jest-preset.js index 50b8f2def..157fbdd2f 100644 --- a/packages/config/jest/jest-preset.js +++ b/packages/config/jest/jest-preset.js @@ -1,6 +1,6 @@ /** @type {import('jest').Config} */ const config = { - preset: "ts-jest", + preset: "ts-jest/presets/js-with-ts", transform: { "^.+\\.ts$": "ts-jest", }, diff --git a/packages/config/typescript/package.json b/packages/config/typescript/package.json index 2618a95f9..30ad45dd0 100644 --- a/packages/config/typescript/package.json +++ b/packages/config/typescript/package.json @@ -7,7 +7,7 @@ "web.json" ], "devDependencies": { - "@tsconfig/recommended": "^1.0.6", + "@tsconfig/recommended": "^1.0.7", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0" } diff --git a/packages/core/_templates/core-module/with-prompt/di-module.ejs.t b/packages/core/_templates/core-module/with-prompt/di-module.ejs.t index e3e1e0587..c8f9e381b 100644 --- a/packages/core/_templates/core-module/with-prompt/di-module.ejs.t +++ b/packages/core/_templates/core-module/with-prompt/di-module.ejs.t @@ -7,7 +7,7 @@ import { types } from "./<%= moduleName %>Types"; type FactoryProps = {}; -const <%= moduleName %>ModuleFactory = ({}: Partial = {}) => +const <%= moduleName %>ModuleFactory = ({}: FactoryProps) => new ContainerModule((bind, _unbind, _isBound, _rebind, _unbindAsync, _onActivation, _onDeactivation) => { bind(types.<%= h.capitalize(moduleName) %>Service).to(Default<%= h.capitalize(moduleName) %>Service); }); diff --git a/packages/core/jest.config.ts b/packages/core/jest.config.ts index ac318d966..16fcf65c3 100644 --- a/packages/core/jest.config.ts +++ b/packages/core/jest.config.ts @@ -1,5 +1,11 @@ /* eslint no-restricted-syntax: 0 */ -import type { JestConfigWithTsJest } from "ts-jest"; +import { JestConfigWithTsJest, pathsToModuleNameMapper } from "ts-jest"; + +import { compilerOptions } from "./tsconfig.json"; + +const internalPaths = pathsToModuleNameMapper(compilerOptions.paths, { + prefix: "/", +}); const config: JestConfigWithTsJest = { preset: "@ledgerhq/jest-config-dsdk", @@ -12,9 +18,7 @@ const config: JestConfigWithTsJest = { "!src/api/index.ts", ], moduleNameMapper: { - "^@api/(.*)$": "/src/api/$1", - "^@internal/(.*)$": "/src/internal/$1", - "^@root/(.*)$": "/$1", + ...internalPaths, }, }; diff --git a/packages/core/package.json b/packages/core/package.json index ceb1a9b6b..95a46f1e8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "@sentry/minimal": "^6.19.7", + "axios": "^1.7.2", "inversify": "^6.0.2", "inversify-logger-middleware": "^3.1.0", "purify-ts": "^2.1.0", diff --git a/packages/core/scripts/build.mjs b/packages/core/scripts/build.mjs index ac064aee1..d80548df4 100644 --- a/packages/core/scripts/build.mjs +++ b/packages/core/scripts/build.mjs @@ -19,4 +19,5 @@ const run = async () => await Promise.all([buildEsm(), builCjs()]); run().catch((e) => { console.error(e); + process.exitCode = e.exitCode; }); diff --git a/packages/core/src/api/DeviceSdk.test.ts b/packages/core/src/api/DeviceSdk.test.ts index e5d05075c..ba3c6648f 100644 --- a/packages/core/src/api/DeviceSdk.test.ts +++ b/packages/core/src/api/DeviceSdk.test.ts @@ -20,7 +20,13 @@ describe("DeviceSdk", () => { describe("clean", () => { beforeEach(() => { logger = new ConsoleLogger(); - sdk = new DeviceSdk({ stub: false, loggers: [logger] }); + sdk = new DeviceSdk({ + stub: false, + loggers: [logger], + config: { + managerApiUrl: "http://fake.url", + }, + }); }); it("should create an instance", () => { @@ -59,7 +65,13 @@ describe("DeviceSdk", () => { describe("stubbed", () => { beforeEach(() => { - sdk = new DeviceSdk({ stub: true, loggers: [] }); + sdk = new DeviceSdk({ + stub: true, + loggers: [], + config: { + managerApiUrl: "http://fake.url", + }, + }); }); it("should create a stubbed sdk", () => { @@ -94,19 +106,4 @@ describe("DeviceSdk", () => { expect(uc.execute()).toBe("stub"); }); }); - - describe("without args", () => { - beforeEach(() => { - sdk = new DeviceSdk(); - }); - - it("should create an instance", () => { - expect(sdk).toBeDefined(); - expect(sdk).toBeInstanceOf(DeviceSdk); - }); - - it("should return a clean `version`", async () => { - expect(await sdk.getVersion()).toBe(pkg.version); - }); - }); }); diff --git a/packages/core/src/api/DeviceSdk.ts b/packages/core/src/api/DeviceSdk.ts index 0216685ae..47fa1ed64 100644 --- a/packages/core/src/api/DeviceSdk.ts +++ b/packages/core/src/api/DeviceSdk.ts @@ -53,11 +53,11 @@ import { SdkError } from "./Error"; export class DeviceSdk { readonly container: Container; /** @internal */ - constructor({ stub, loggers }: Partial = {}) { + constructor({ stub, loggers, config }: MakeContainerProps) { // NOTE: MakeContainerProps might not be the exact type here // For the init of the project this is sufficient, but we might need to // update the constructor arguments as we go (we might have more than just the container config) - this.container = makeContainer({ stub, loggers }); + this.container = makeContainer({ stub, loggers, config }); } /** diff --git a/packages/core/src/api/DeviceSdkBuilder.ts b/packages/core/src/api/DeviceSdkBuilder.ts index c169e3c80..12b38e942 100644 --- a/packages/core/src/api/DeviceSdkBuilder.ts +++ b/packages/core/src/api/DeviceSdkBuilder.ts @@ -1,5 +1,8 @@ +import { DEFAULT_MANAGER_API_BASE_URL } from "@internal/manager-api/model/Const"; + import { LoggerSubscriberService } from "./logger-subscriber/service/LoggerSubscriberService"; import { DeviceSdk } from "./DeviceSdk"; +import { SdkConfig } from "./SdkConfig"; /** * Builder for the `DeviceSdk` class. @@ -15,9 +18,16 @@ import { DeviceSdk } from "./DeviceSdk"; export class LedgerDeviceSdkBuilder { private stub = false; private readonly loggers: LoggerSubscriberService[] = []; + private config: SdkConfig = { + managerApiUrl: DEFAULT_MANAGER_API_BASE_URL, + }; build(): DeviceSdk { - return new DeviceSdk({ stub: this.stub, loggers: this.loggers }); + return new DeviceSdk({ + stub: this.stub, + loggers: this.loggers, + config: this.config, + }); } setStub(stubbed: boolean): LedgerDeviceSdkBuilder { @@ -32,4 +42,12 @@ export class LedgerDeviceSdkBuilder { this.loggers.push(logger); return this; } + + addConfig(config: SdkConfig): LedgerDeviceSdkBuilder { + this.config = { + ...this.config, + ...config, + }; + return this; + } } diff --git a/packages/core/src/api/SdkConfig.ts b/packages/core/src/api/SdkConfig.ts new file mode 100644 index 000000000..a910a5f2b --- /dev/null +++ b/packages/core/src/api/SdkConfig.ts @@ -0,0 +1,3 @@ +export type SdkConfig = { + managerApiUrl: string; +}; diff --git a/packages/core/src/api/apdu/utils/ApduBuilder.ts b/packages/core/src/api/apdu/utils/ApduBuilder.ts index 1db6ff2d7..9ab0f712b 100644 --- a/packages/core/src/api/apdu/utils/ApduBuilder.ts +++ b/packages/core/src/api/apdu/utils/ApduBuilder.ts @@ -63,14 +63,15 @@ export class ApduBuilder { * Build a new Apdu instance with the current state of the builder * @returns {Apdu} - Returns a new Apdu instance */ - build = () => new Apdu(this._cla, this._ins, this._p1, this.p2, this.data); + build = (): Apdu => + new Apdu(this._cla, this._ins, this._p1, this.p2, this.data); /** * Add a 8-bit unsigned integer to the data field (max value 0xff = 255) * @param value?: number - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - add8BitUIntToData = (value?: number) => { + add8BitUIntToData = (value?: number): ApduBuilder => { if (typeof value === "undefined" || isNaN(value)) { this.errors?.push(new InvalidValueError("byte", value?.toString())); return this; @@ -97,7 +98,7 @@ export class ApduBuilder { * @param value: number - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - add16BitUIntToData = (value: number) => { + add16BitUIntToData = (value: number): ApduBuilder => { if (value > MAX_16_BIT_UINT) { this.errors?.push( new ValueOverflowError(value.toString(), MAX_16_BIT_UINT), @@ -120,7 +121,7 @@ export class ApduBuilder { * @param value: number - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - add32BitUIntToData = (value: number) => { + add32BitUIntToData = (value: number): ApduBuilder => { if (value > MAX_32_BIT_UINT) { this.errors?.push( new ValueOverflowError(value.toString(), MAX_32_BIT_UINT), @@ -145,7 +146,7 @@ export class ApduBuilder { * @param value: Uint8Array - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - addBufferToData = (value: Uint8Array) => { + addBufferToData = (value: Uint8Array): ApduBuilder => { if (!this.hasEnoughLengthRemaining(value)) { this.errors?.push(new DataOverflowError(value.toString())); return this; @@ -163,7 +164,7 @@ export class ApduBuilder { * @param value: string - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - addHexaStringToData = (value: string) => { + addHexaStringToData = (value: string): ApduBuilder => { const result = this.getHexaString(value); if (!result.length) { this.errors?.push(new HexaStringEncodeError(value)); @@ -178,7 +179,7 @@ export class ApduBuilder { * @param value: string - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - addAsciiStringToData = (value: string) => { + addAsciiStringToData = (value: string): ApduBuilder => { let hexa = 0; if (!this.hasEnoughLengthRemaining(value)) { @@ -201,7 +202,7 @@ export class ApduBuilder { * @param value: string - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - encodeInLVFromHexa = (value: string) => { + encodeInLVFromHexa = (value: string): ApduBuilder => { const result: number[] = this.getHexaString(value); if (!result.length) { @@ -227,7 +228,7 @@ export class ApduBuilder { * @param value: Uint8Array - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - encodeInLVFromBuffer = (value: Uint8Array) => { + encodeInLVFromBuffer = (value: Uint8Array): ApduBuilder => { if (!this.hasEnoughLengthRemaining(value, true)) { this.errors?.push(new DataOverflowError(value.toString())); return this; @@ -245,7 +246,7 @@ export class ApduBuilder { * @param value: string - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - encodeInLVFromAscii = (value: string) => { + encodeInLVFromAscii = (value: string): ApduBuilder => { if (!this.hasEnoughLengthRemaining(value, true)) { this.errors?.push(new DataOverflowError(value)); return this; @@ -260,7 +261,7 @@ export class ApduBuilder { * Returns the remaining payload length * @returns {number} */ - getAvailablePayloadLength = () => { + getAvailablePayloadLength = (): number => { return APDU_MAX_SIZE - (HEADER_LENGTH + (this.data?.length ?? 0)); }; @@ -269,7 +270,7 @@ export class ApduBuilder { * @param value: string - The value to convert to hexadecimal * @returns {number[]} - Returns an array of numbers representing the hexadecimal value */ - getHexaString = (value: string) => { + getHexaString = (value: string): number[] => { const table: number[] = []; if (!value.length) return []; @@ -307,7 +308,7 @@ export class ApduBuilder { * Returns the current errors * @returns {AppBuilderError[]} - Returns an array of errors */ - getErrors = () => this.errors; + getErrors = (): AppBuilderError[] => this.errors; // =========== // Private API @@ -321,8 +322,8 @@ export class ApduBuilder { */ private hasEnoughLengthRemaining = ( value: string | Uint8Array | number[], - hasLv = false, - ) => { + hasLv: boolean = false, + ): boolean => { return ( HEADER_LENGTH + (this.data?.length ?? 0) + @@ -337,7 +338,7 @@ export class ApduBuilder { * @param value: number[] - The value to add to the data * @returns {ApduBuilder} - Returns the current instance of ApduBuilder */ - private addNumbers = (value: number[]) => { + private addNumbers = (value: number[]): ApduBuilder => { if (!this.hasEnoughLengthRemaining(value)) { this.errors?.push(new DataOverflowError(value.toString())); return this; diff --git a/packages/core/src/api/apdu/utils/ApduParser.ts b/packages/core/src/api/apdu/utils/ApduParser.ts index b36a9ed3f..80449262f 100644 --- a/packages/core/src/api/apdu/utils/ApduParser.ts +++ b/packages/core/src/api/apdu/utils/ApduParser.ts @@ -1,4 +1,5 @@ import { ApduResponse } from "@api/device-session/ApduResponse"; +import { HexaString } from "@api/utils/HexaString"; export type TaggedField = { readonly tag: number; @@ -127,20 +128,23 @@ export class ApduParser { * @param prefix {boolean} - Whether to add a prefix to the encoded value * @returns {string} - The encoded value as a hexadecimal string */ - encodeToHexaString(value?: Uint8Array, prefix?: boolean): string { + encodeToHexaString(value?: Uint8Array, prefix?: false): string; + encodeToHexaString(value?: Uint8Array, prefix?: true): HexaString; + encodeToHexaString( + value?: Uint8Array, + prefix: boolean = false, + ): HexaString | string { let result = ""; let index = 0; if (!value) return result; - if (prefix) result += "0x"; - while (index <= value.length) { const item = value[index]?.toString(16); if (item) result += item.length < 2 ? "0" + item : item; index++; } - return result; + return prefix ? `0x${result}` : result; } /** diff --git a/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts b/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts index 7822a416a..412df80cf 100644 --- a/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts +++ b/packages/core/src/api/command/use-case/SendCommandUseCase.test.ts @@ -6,11 +6,17 @@ import { DefaultDeviceSessionService } from "@internal/device-session/service/De import { DeviceSessionService } from "@internal/device-session/service/DeviceSessionService"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { SendCommandUseCase } from "./SendCommandUseCase"; let logger: LoggerPublisherService; let sessionService: DeviceSessionService; +let managerApi: ManagerApiService; +let managerApiDataSource: ManagerApiDataSource; const fakeSessionId = "fakeSessionId"; let command: Command<{ status: string }>; @@ -18,6 +24,10 @@ describe("SendCommandUseCase", () => { beforeEach(() => { logger = new DefaultLoggerPublisherService([], "send-command-use-case"); sessionService = new DefaultDeviceSessionService(() => logger); + managerApiDataSource = new AxiosManagerApiDataSource({ + managerApiUrl: "http://fake.url", + }); + managerApi = new DefaultManagerApiService(managerApiDataSource); command = { getApdu: jest.fn(), parseResponse: jest.fn(), @@ -29,7 +39,11 @@ describe("SendCommandUseCase", () => { }); it("should send a command to a connected device", async () => { - const deviceSession = deviceSessionStubBuilder({}, () => logger); + const deviceSession = deviceSessionStubBuilder( + {}, + () => logger, + managerApi, + ); sessionService.addDeviceSession(deviceSession); const useCase = new SendCommandUseCase(sessionService, () => logger); diff --git a/packages/core/src/api/device-action/DeviceAction.ts b/packages/core/src/api/device-action/DeviceAction.ts index ef4b67989..ba404f1bd 100644 --- a/packages/core/src/api/device-action/DeviceAction.ts +++ b/packages/core/src/api/device-action/DeviceAction.ts @@ -3,6 +3,7 @@ import { Observable } from "rxjs"; import { Command } from "@api/command/Command"; import { DeviceSessionState } from "@api/device-session/DeviceSessionState"; import { SdkError } from "@api/Error"; +import { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { DeviceActionState } from "./model/DeviceActionState"; @@ -15,6 +16,7 @@ export type InternalApi = { readonly setDeviceSessionState: ( state: DeviceSessionState, ) => DeviceSessionState; + getMetadataForAppHashes: ManagerApiService["getAppsByHash"]; }; export type DeviceActionIntermediateValue = { diff --git a/packages/core/src/api/device-action/__test-utils__/data.ts b/packages/core/src/api/device-action/__test-utils__/data.ts new file mode 100644 index 000000000..2057d573a --- /dev/null +++ b/packages/core/src/api/device-action/__test-utils__/data.ts @@ -0,0 +1,110 @@ +import { AppType } from "@internal/manager-api/model/ManagerApiType"; + +export const BTC_APP = { + appEntryLength: 77, + appSizeInBlocks: 3227, + appCodeHash: + "924b5ba590971b3e98537cf8241f0aa51b1e6f26c37915dd38b83255168255d5", + appFullHash: + "81e73bd232ef9b26c00a152cb291388fb3ded1a2db6b44f53b3119d91d2879bb", + appName: "Bitcoin", +}; + +export const BTC_APP_METADATA = { + versionId: 36248, + versionName: "Bitcoin", + versionDisplayName: "Bitcoin", + version: "2.2.2", + currencyId: "bitcoin", + description: "", + applicationType: AppType.currency, + dateModified: "2024-04-08T11:31:34.847313Z", + icon: "bitcoin", + authorName: " Ledger", + supportURL: + "https://support.ledger.com/hc/en-us/articles/115005195945-Bitcoin-BTC-", + contactURL: "mailto:https://support.ledger.com/hc/en-us/requests/new", + sourceURL: "https://github.com/LedgerHQ/app-bitcoin-new", + compatibleWallets: + '[ { "name": "Electrum", "url": "https://electrum.org/#home" } ]', + hash: "81e73bd232ef9b26c00a152cb291388fb3ded1a2db6b44f53b3119d91d2879bb", + perso: "perso_11", + firmware: "stax/1.4.0-rc2/bitcoin/app_2.2.2", + firmwareKey: "stax/1.4.0-rc2/bitcoin/app_2.2.2_key", + delete: "stax/1.4.0-rc2/bitcoin/app_2.2.2_del", + deleteKey: "stax/1.4.0-rc2/bitcoin/app_2.2.2_del_key", + bytes: 103264, + warning: null, + isDevTools: false, + category: 1, + parent: null, + parentName: null, +}; + +export const CUSTOM_LOCK_SCREEN_APP = { + appEntryLength: 70, + appSizeInBlocks: 1093, + appCodeHash: + "0000000000000000000000000000000000000000000000000000000000000000", + appFullHash: + "5602b3d3fdde77fc02eb451a8beec4155bcf8b83ced794d7b3c63afaed5ff8c6", + appName: "", +}; +export const CUSTOM_LOCK_SCREEN_APP_METADATA = null; +export const ETH_APP = { + appEntryLength: 78, + appSizeInBlocks: 4120, + appCodeHash: + "4fdb751c0444f3a982c2ae9dcfde6ebe6dab03613d496f5e53cf91bce8ca46b5", + appFullHash: + "c7507c742ce3f8ec446b1ebda18159a5d432241a7199c3fc2401e72adfa9ab38", + appName: "Ethereum", +}; +export const ETH_APP_METADATA = { + versionId: 36185, + versionName: "Ethereum", + versionDisplayName: "Ethereum", + version: "1.10.4", + currencyId: "ethereum", + description: "", + applicationType: AppType.currency, + dateModified: "2024-04-09T12:28:55.783551Z", + icon: "ethereum", + authorName: " Ledger", + supportURL: + "https://support.ledger.com/hc/en-us/articles/360009576554-Ethereum-ETH-", + contactURL: "mailto:https://support.ledger.com/hc/en-us/requests/new", + sourceURL: "https://github.com/LedgerHQ/app-ethereum", + compatibleWallets: + '[ { "name": "Metamask", "url": "https://metamask.io/" }, { "name": "Phantom", "url": "https://phantom.app/" }, { "name": "Rabby", "url": "https://rabby.io/" }, { "name": "Rainbow", "url": "https://rainbow.me/" }, { "name": "MyCrypto", "url": "https://www.ledger.com/mycrypto/" }, { "name": "MyEtherWallet", "url": "https://www.ledger.com/myetherwallet/" } ]', + hash: "c7507c742ce3f8ec446b1ebda18159a5d432241a7199c3fc2401e72adfa9ab38", + perso: "perso_11", + firmware: "stax/1.4.0-rc3/ethereum/app_1.10.4", + firmwareKey: "stax/1.4.0-rc3/ethereum/app_1.10.4_key", + delete: "stax/1.4.0-rc3/ethereum/app_1.10.4_del", + deleteKey: "stax/1.4.0-rc3/ethereum/app_1.10.4_del_key", + bytes: 131852, + warning: "", + isDevTools: false, + category: 1, + parent: null, + parentName: null, +}; +export const SOLANA_APP = { + appEntryLength: 76, + appSizeInBlocks: 2568, + appCodeHash: + "dcc77e385de4394f579fa7b6eeb7293950fe5aec6d5355a7049f77bc0d02de24", + appFullHash: + "afbdaa67241e21c00191b177198615b50c98e5db998c3bba1d78093a85dbedee", + appName: "Solana", +}; +export const DOGECOIN_APP = { + appEntryLength: 78, + appSizeInBlocks: 2458, + appCodeHash: + "e59eee7bd32b1af2d93c5d8211e33d844d153a710d800254ea754e10ce18e7a9", + appFullHash: + "227001130f66297406696a19e1cf1e8e8b0cc14d5824ae8b1da98122c322e22e", + appName: "Dogecoin", +}; diff --git a/packages/core/src/api/device-action/__test-utils__/makeInternalApi.ts b/packages/core/src/api/device-action/__test-utils__/makeInternalApi.ts new file mode 100644 index 000000000..5db4c7416 --- /dev/null +++ b/packages/core/src/api/device-action/__test-utils__/makeInternalApi.ts @@ -0,0 +1,17 @@ +import { InternalApi } from "@api/device-action/DeviceAction"; + +const sendCommandMock = jest.fn(); +const apiGetDeviceSessionStateMock = jest.fn(); +const apiGetDeviceSessionStateObservableMock = jest.fn(); +const setDeviceSessionStateMock = jest.fn(); +const getMetadataForAppHashesMock = jest.fn(); + +export function makeDeviceActionInternalApiMock(): jest.Mocked { + return { + sendCommand: sendCommandMock, + getDeviceSessionState: apiGetDeviceSessionStateMock, + getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock, + setDeviceSessionState: setDeviceSessionStateMock, + getMetadataForAppHashes: getMetadataForAppHashesMock, + }; +} diff --git a/packages/core/src/api/device-action/__test-utils__/setupTestMachine.ts b/packages/core/src/api/device-action/__test-utils__/setupTestMachine.ts new file mode 100644 index 000000000..2e2b23a55 --- /dev/null +++ b/packages/core/src/api/device-action/__test-utils__/setupTestMachine.ts @@ -0,0 +1,109 @@ +import { Left, Right } from "purify-ts"; +import { assign, createMachine } from "xstate"; + +import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; +import { UnknownDAError } from "@api/device-action/os/Errors"; +import { GetDeviceStatusDeviceAction } from "@api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction"; +import { GoToDashboardDeviceAction } from "@api/device-action/os/GoToDashboard/GoToDashboardDeviceAction"; +import { ListAppsDeviceAction } from "@api/device-action/os/ListApps/ListAppsDeviceAction"; +import { SdkError } from "@api/Error"; + +import { BTC_APP } from "./data"; + +type App = typeof BTC_APP; + +export const setupListAppsMock = (apps: App[], error = false) => { + (ListAppsDeviceAction as jest.Mock).mockImplementation(() => ({ + makeStateMachine: jest.fn().mockImplementation(() => + createMachine({ + id: "MockListAppsDeviceAction", + initial: "ready", + states: { + ready: { + after: { + 0: "done", + }, + entry: assign({ + intermediateValue: () => ({ + requiredUserInteraction: UserInteractionRequired.AllowListApps, + }), + }), + }, + done: { + type: "final", + }, + }, + output: () => { + return error + ? Left(new UnknownDAError("ListApps failed")) + : Right(apps); + }, + }), + ), + })); +}; + +export const setupGoToDashboardMock = (error: boolean = false) => { + (GoToDashboardDeviceAction as jest.Mock).mockImplementation(() => ({ + makeStateMachine: jest.fn().mockImplementation(() => + createMachine({ + id: "MockGoToDashboardDeviceAction", + initial: "ready", + states: { + ready: { + after: { + 0: "done", + }, + entry: assign({ + intermediateValue: () => ({ + requiredUserInteraction: UserInteractionRequired.None, + }), + }), + }, + done: { + type: "final", + }, + }, + output: () => { + return error + ? Left(new UnknownDAError("GoToDashboard failed")) + : Right(undefined); + }, + }), + ), + })); +}; + +export const setupGetDeviceStatusMock = ( + output: { currentApp: string; currentAppVersion: string } | SdkError = { + currentApp: "BOLOS", + currentAppVersion: "1.0.0", + }, +) => { + (GetDeviceStatusDeviceAction as jest.Mock).mockImplementation(() => ({ + makeStateMachine: jest.fn().mockImplementation(() => + createMachine({ + id: "MockGetDeviceStatusDeviceAction", + initial: "ready", + states: { + ready: { + after: { + 0: "done", + }, + entry: assign({ + intermediateValue: () => ({ + requiredUserInteraction: UserInteractionRequired.None, + }), + }), + }, + done: { + type: "final", + }, + }, + output: () => { + return "currentApp" in output ? Right(output) : Left(output); + }, + }), + ), + })); +}; diff --git a/packages/core/src/api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction.test.ts b/packages/core/src/api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction.test.ts index 0a2c0d11b..d738575d9 100644 --- a/packages/core/src/api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction.test.ts +++ b/packages/core/src/api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction.test.ts @@ -1,8 +1,8 @@ import { interval, Observable } from "rxjs"; import { DeviceStatus } from "@api/device/DeviceStatus"; +import { makeDeviceActionInternalApiMock } from "@api/device-action/__test-utils__/makeInternalApi"; import { testDeviceActionStates } from "@api/device-action/__test-utils__/testDeviceActionStates"; -import { InternalApi } from "@api/device-action/DeviceAction"; import { DeviceActionStatus } from "@api/device-action/model/DeviceActionState"; import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; import { @@ -32,20 +32,11 @@ describe("GetDeviceStatusDeviceAction", () => { }; } - const sendCommandMock = jest.fn(); - const apiGetDeviceSessionStateMock = jest.fn(); - const apiGetDeviceSessionStateObservableMock = jest.fn(); - const setDeviceSessionStateMock = jest.fn(); - - function internalApiMock(): InternalApi { - return { - sendCommand: sendCommandMock, - getDeviceSessionState: apiGetDeviceSessionStateMock, - getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock, - setDeviceSessionState: setDeviceSessionStateMock, - }; - } - + const { + sendCommand: sendCommandMock, + getDeviceSessionState: apiGetDeviceSessionStateMock, + getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock, + } = makeDeviceActionInternalApiMock(); beforeEach(() => { jest.resetAllMocks(); isDeviceOnboardedMock.mockReturnValue(true); @@ -60,7 +51,6 @@ describe("GetDeviceStatusDeviceAction", () => { apiGetDeviceSessionStateMock.mockReturnValue({ sessionStateType: DeviceSessionStateType.Connected, deviceStatus: DeviceStatus.CONNECTED, - currentApp: "mockedCurrentApp", }); sendCommandMock.mockResolvedValue({ @@ -93,7 +83,7 @@ describe("GetDeviceStatusDeviceAction", () => { testDeviceActionStates( getDeviceStateDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -107,6 +97,7 @@ describe("GetDeviceStatusDeviceAction", () => { sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: DeviceStatus.LOCKED, currentApp: "mockedCurrentApp", + installedApps: [], }); apiGetDeviceSessionStateObservableMock.mockImplementation( @@ -120,6 +111,7 @@ describe("GetDeviceStatusDeviceAction", () => { DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: DeviceStatus.CONNECTED, currentApp: "mockedCurrentApp", + installedApps: [], }); o.complete(); } else { @@ -128,6 +120,7 @@ describe("GetDeviceStatusDeviceAction", () => { DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: DeviceStatus.LOCKED, currentApp: "mockedCurrentApp", + installedApps: [], }); } }, @@ -175,7 +168,7 @@ describe("GetDeviceStatusDeviceAction", () => { testDeviceActionStates( getDeviceStateDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -227,7 +220,7 @@ describe("GetDeviceStatusDeviceAction", () => { testDeviceActionStates( getDeviceStateDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -313,7 +306,7 @@ describe("GetDeviceStatusDeviceAction", () => { testDeviceActionStates( getDeviceStateDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -346,7 +339,7 @@ describe("GetDeviceStatusDeviceAction", () => { testDeviceActionStates( getDeviceStateDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -368,6 +361,7 @@ describe("GetDeviceStatusDeviceAction", () => { DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: DeviceStatus.LOCKED, currentApp: "mockedCurrentApp", + installedApps: [], }); }, }); @@ -402,7 +396,7 @@ describe("GetDeviceStatusDeviceAction", () => { testDeviceActionStates( getDeviceStateDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -478,7 +472,7 @@ describe("GetDeviceStatusDeviceAction", () => { testDeviceActionStates( getDeviceStateDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -561,7 +555,7 @@ describe("GetDeviceStatusDeviceAction", () => { testDeviceActionStates( getDeviceStateDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -569,9 +563,10 @@ describe("GetDeviceStatusDeviceAction", () => { it("should emit a stopped state if the action is cancelled", (done) => { apiGetDeviceSessionStateMock.mockReturnValue({ - sessionStateType: DeviceSessionStateType.Connected, + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: DeviceStatus.CONNECTED, currentApp: "mockedCurrentApp", + installedApps: [], }); sendCommandMock.mockResolvedValue({ @@ -598,7 +593,7 @@ describe("GetDeviceStatusDeviceAction", () => { const { cancel } = testDeviceActionStates( getDeviceStateDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); cancel(); diff --git a/packages/core/src/api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction.ts b/packages/core/src/api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction.ts index d782faae6..f0cecfa64 100644 --- a/packages/core/src/api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction.ts +++ b/packages/core/src/api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction.ts @@ -162,7 +162,7 @@ export class GetDeviceStatusDeviceAction extends XStateDeviceAction< }), }, }).createMachine({ - /** @xstate-layout N4IgpgJg5mDOIC5QHEwBcAiYBuBLAxmAMpoCGaArrFnoQIL5q4D2AdgHQ0FgBKYpEAJ4BiANoAGALqJQAB2axcTNjJAAPRAEYAnOPYAOAGwB2TfoDMh8QBZz4gEyGANCEGIArMe3tN5+-vFte3dzbWt7awBfSJdUTBxuEnIqLnpGFg4AeVYAI2ZSACcIXFYoAGEACzB8AGsxKVV5RWVWVQ0Ea1MDd21zfWD3d2txcXcXNwR3Q3N2P3tdf3tNUfNjaNj0VOIySmoEtJb2bLzC4tLK6rrRTWkkECalDLbETs1u3v7BoZGx10R9N7uEbiTSdTzGcxDTTrEBxLZJXZbBiHAAyzFqJXKVVq9VucgUjxUd3auj04kMwXs5O0+gBIPGHkM3gh2kGEQB-nc+hhcP222Se1oYGRGXYaIx52xVxujQJLWeCFJ7HJlOptOWmgZHVB7GshkMy3CrO0mnmURisM2fIRKT5IrY7AAqrAwAV7axHawADbompbYQQNhgdglbDMGrB52u92en21LYSPH3OVPYlaIEzTzicyaYymVmBTV-bWGAz2ebZrnTV7mjbxIU2wXcd1Ol1u9JsWO+-2ugrMArsWRe8gAM37AFtW9GOx7vd2+YnZc1U6B2poM+wszm8zogSateX7OxtMZDENrFybEs-DyrQ2drahS26LJZHRWBAAGquxRsC44wNWGDUNw2DXl7wFJEZ3YF83w-b8Cl-Vh-xqBAQPwcgMkTRc7geeU0wQKlNDeOxrFpaxdE0ExnGLKZ9B8KZ7Bzdxyz6Exb3rRIHybA5RVg98vx-DIUOEXt+0HYc0DHApJ3ArjILtaD+PgoS-ylNDWDDDCWmwhpcJTIlV0QIiSJscjKOorVLHcdgqRzU0QQvMxOg4+FuKgw4iFIbBhVfBEwADIMcPxZdDPUYzOlLU1vmGLx9G0EwtSsGZaXsU8yJ0cRjHmVzrXcxTPO83zZH80SCj7ApguTULWgIiJTx8YJhlimkEuMLUgWMXV5hMMIyINKZogtVhmAgOBVDkwhGw8ldqsJWqjIQABaGiJiWmztE2rbtq2yxcogxECtFLY+AECYQvmhVwg64x6N8fwLBpIwpnMfb5MOp9oOOfIikxFCl0uur9A2kJiKYuxdEhawbruvxaVCWkz2mN6pvyz7UV9P6pQB-DFuMcJZn6YHjBYsIEuh2j9GsTcKJCSwyNpYGUf5D7m2gqN2xaLt4z5HHZrXZYuq3BHSbCKyQnYUwDTIjKmX6NYLUmlnHzZw5lMExDhOx-SaoVXMz2VEYqLzYYmVPDqWMJ-xyXMaxbGy5npqOh0vJ82D-L5sL2lYmzPCpqYyK8O3fgmKZS2mNKhncYjBj1R20dV0UiAofBCFgeAdcBxa0p1GwIXCL4aSLCZkp8OGzFPYwHAVus3IU9HRQAUXK-tPYW8LCNMam89tgYegBKyAVmHqmqsa3tCGyIgA */ + /** @xstate-layout N4IgpgJg5mDOIC5QHEwBcAiYBuBLAxmAMpoCGaArrFnoQIL5q4D2AdgHQ0FgBKYpEAJ4BiANoAGALqJQAB2axcTNjJAAPRAEYAnOPYAOAGwB2AEzjxmgMzbj+4wBZDAGhCDEAVmPb2107tN9K2D9UwBfMNdUTBxuEnIqLnpGFg4AeVYAI2ZSACcIXFYoAGEACzB8AGsxKVV5RWVWVQ0EB2NNAw9tKyDta3Egq1d3BA9DK3YrU38HcUMPD0sBiKj0JOIySmpY5Mb2DOy8gqKyiurRTWkkEHqlVObENo79Lp6bfsHhxH0Oh21--R9BxGOaGbQrEDRdbxLbrBh7AAyzCqhRK5SqNSucgUdxU1xauj0c1MhmmfSsTxcbk8YPYxhs8ysmk0HiZrIhUJ2GwS21oYHhqXYSJRJ3R50udRxjQeCEJ7GJpP81kpX1amgc7Cchk0QWmL3+xg5ay5MMSXIFbHYAFVYGBchbWFbWAAbZGVdbCCBsMDsQrYZiVH02u0Op2uqrrCRYm5S+74rSLCZecSzbTjGzBYyqhyaQwGMltPpdcQecKRSHGvmm3ncB3W232lJsMNuj123LMXLsWTO8gAM07AFt6yGm46Xa2uVHJQ046AWizxEnjCnxGngt0rFnqQhpqZ2LZ5trjCvFiYjTEq5szXy63RZLI6KwIAA1O2KNinDFe1g+v0Bn1OSvHk4THdh70fZ831yD9WC-SoEH-fByFSKNp2uW5pXjXdLA6KwU30HMSz6dV9FVMZ9F8MZTDGcRTH6NML2ha8a12QUIKfV931SeDhHbTtu17NAB1yYcgLiFjQL2DioO4z8xUQ1h-WQxo0NqDDYzxedEHMZlJgIoiumZYFVSseZ2HMJkmToswzAWJiTUk80wKIUhsH5B8YTAT1vXQ7FZy09QdLaPNNBoilNBXOj-g8VVDCXAxQlCVktyXGwHOA2FnL2Vz3Igry+NyDtcj8mMAqabDTBC3xwpzKL-G0WKd0WYxNX8erCKXQiInLVhmAgOBVHEwhqykucytxCrtIQABaKkRhmjwD3+FbVpW7wMokkDssFdY+AEEZ-MmmUHFMci7F8KZCKqrdCJ6TaRqc28wIOHJ8lReCZ2OyqXgPVkWRsNcHC6BxzsovxrocW6of0B7uSy57ETdD6xS+rDpscfcekCRqdVCNLyMI9gPD+LxbIiw1y2G+Gb1rMDg0bRoWwjLk0fGhdNHEVrkwcVNIu6UxtxGKxWTpXNTApfwPB0Lw4dGnbLRkriYJ41GNPKmVIvMixcO0HNgnMebPFLSYkrC0tFlmSnVkvLaEbpnK3I82QvLZwKWmmEXibsWYpglxrWXIww83GQIWXaKZ-gcOWnodwUiAofBCFgeB1e+6bBfVeUniBU6T20OKEohgZkymHQY+2xHBQAUSKzs3amoLd3aDUU3aXPBe8Uyfkmdqxka2wix6sIgA */ id: "GetDeviceStatusDeviceAction", initial: "DeviceReady", context: (_) => { diff --git a/packages/core/src/api/device-action/os/GoToDashboard/GoToDashboardDeviceAction.test.ts b/packages/core/src/api/device-action/os/GoToDashboard/GoToDashboardDeviceAction.test.ts index ad1bf2191..225461ef6 100644 --- a/packages/core/src/api/device-action/os/GoToDashboard/GoToDashboardDeviceAction.test.ts +++ b/packages/core/src/api/device-action/os/GoToDashboard/GoToDashboardDeviceAction.test.ts @@ -1,55 +1,17 @@ -import { Left, Right } from "purify-ts"; -import { assign, createMachine } from "xstate"; - import { DeviceStatus } from "@api/device/DeviceStatus"; +import { makeDeviceActionInternalApiMock } from "@api/device-action/__test-utils__/makeInternalApi"; +import { setupGetDeviceStatusMock } from "@api/device-action/__test-utils__/setupTestMachine"; import { testDeviceActionStates } from "@api/device-action/__test-utils__/testDeviceActionStates"; -import { InternalApi } from "@api/device-action/DeviceAction"; import { DeviceActionStatus } from "@api/device-action/model/DeviceActionState"; import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; import { UnknownDAError } from "@api/device-action/os/Errors"; -import { GetDeviceStatusDeviceAction } from "@api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction"; import { DeviceSessionStateType } from "@api/device-session/DeviceSessionState"; -import { SdkError } from "@api/Error"; import { GoToDashboardDeviceAction } from "./GoToDashboardDeviceAction"; import { GoToDashboardDAState } from "./types"; jest.mock("@api/device-action/os/GetDeviceStatus/GetDeviceStatusDeviceAction"); -const setupGetDeviceStatusMock = ( - output: { currentApp: string; currentAppVersion: string } | SdkError = { - currentApp: "BOLOS", - currentAppVersion: "1.0.0", - }, -) => { - (GetDeviceStatusDeviceAction as jest.Mock).mockImplementation(() => ({ - makeStateMachine: jest.fn().mockImplementation(() => - createMachine({ - id: "MockGetDeviceStatusDeviceAction", - initial: "ready", - states: { - ready: { - after: { - 0: "done", - }, - entry: assign({ - intermediateValue: () => ({ - requiredUserInteraction: UserInteractionRequired.None, - }), - }), - }, - done: { - type: "final", - }, - }, - output: () => { - return "currentApp" in output ? Right(output) : Left(output); - }, - }), - ), - })); -}; - describe("GoToDashboardDeviceAction", () => { const closeAppMock = jest.fn(); const getAppAndVersionMock = jest.fn(); @@ -65,19 +27,10 @@ describe("GoToDashboardDeviceAction", () => { }; } - const sendCommandMock = jest.fn(); - const apiGetDeviceSessionStateMock = jest.fn(); - const apiGetDeviceSessionStateObservableMock = jest.fn(); - const setDeviceSessionStateMock = jest.fn(); - - function internalApiMock(): InternalApi { - return { - sendCommand: sendCommandMock, - getDeviceSessionState: apiGetDeviceSessionStateMock, - getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock, - setDeviceSessionState: setDeviceSessionStateMock, - }; - } + const { + sendCommand: sendCommandMock, + getDeviceSessionState: apiGetDeviceSessionStateMock, + } = makeDeviceActionInternalApiMock(); beforeEach(() => { jest.resetAllMocks(); @@ -92,9 +45,10 @@ describe("GoToDashboardDeviceAction", () => { }); apiGetDeviceSessionStateMock.mockReturnValue({ - sessionStateType: DeviceSessionStateType.Connected, + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: DeviceStatus.CONNECTED, currentApp: "BOLOS", + installedApps: [], }); const expectedStates: Array = [ @@ -125,7 +79,7 @@ describe("GoToDashboardDeviceAction", () => { testDeviceActionStates( goToDashboardDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -141,9 +95,10 @@ describe("GoToDashboardDeviceAction", () => { }); apiGetDeviceSessionStateMock.mockReturnValue({ - sessionStateType: DeviceSessionStateType.Connected, + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: DeviceStatus.CONNECTED, currentApp: "Bitcoin", + installedApps: [], }); sendCommandMock.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ @@ -191,7 +146,7 @@ describe("GoToDashboardDeviceAction", () => { testDeviceActionStates( goToDashboardDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -248,7 +203,7 @@ describe("GoToDashboardDeviceAction", () => { testDeviceActionStates( goToDashboardDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -303,7 +258,7 @@ describe("GoToDashboardDeviceAction", () => { testDeviceActionStates( goToDashboardDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -318,9 +273,10 @@ describe("GoToDashboardDeviceAction", () => { }); apiGetDeviceSessionStateMock.mockReturnValue({ - sessionStateType: DeviceSessionStateType.Connected, + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: DeviceStatus.CONNECTED, currentApp: "BOLOS", + installedApps: [], }); const expectedStates: Array = [ @@ -345,7 +301,7 @@ describe("GoToDashboardDeviceAction", () => { testDeviceActionStates( goToDashboardDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -401,7 +357,7 @@ describe("GoToDashboardDeviceAction", () => { testDeviceActionStates( goToDashboardDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -465,7 +421,7 @@ describe("GoToDashboardDeviceAction", () => { testDeviceActionStates( goToDashboardDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -530,7 +486,7 @@ describe("GoToDashboardDeviceAction", () => { testDeviceActionStates( goToDashboardDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -605,7 +561,7 @@ describe("GoToDashboardDeviceAction", () => { testDeviceActionStates( goToDashboardDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); diff --git a/packages/core/src/api/device-action/os/ListApps/ListAppsDeviceAction.test.ts b/packages/core/src/api/device-action/os/ListApps/ListAppsDeviceAction.test.ts index 478e282b1..13b2ccd8f 100644 --- a/packages/core/src/api/device-action/os/ListApps/ListAppsDeviceAction.test.ts +++ b/packages/core/src/api/device-action/os/ListApps/ListAppsDeviceAction.test.ts @@ -1,114 +1,28 @@ -import { Left, Right } from "purify-ts"; -import { assign, createMachine } from "xstate"; - +import { ListAppsResponse } from "@api/command/os/ListAppsCommand"; +import { + BTC_APP, + CUSTOM_LOCK_SCREEN_APP, + DOGECOIN_APP, + ETH_APP, + SOLANA_APP, +} from "@api/device-action/__test-utils__/data"; +import { makeDeviceActionInternalApiMock } from "@api/device-action/__test-utils__/makeInternalApi"; +import { setupGoToDashboardMock } from "@api/device-action/__test-utils__/setupTestMachine"; import { testDeviceActionStates } from "@api/device-action/__test-utils__/testDeviceActionStates"; -import { InternalApi } from "@api/device-action/DeviceAction"; import { DeviceActionStatus } from "@api/device-action/model/DeviceActionState"; import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; import { ListAppsRejectedError, UnknownDAError, } from "@api/device-action/os/Errors"; -import { GoToDashboardDeviceAction } from "@api/device-action/os/GoToDashboard/GoToDashboardDeviceAction"; import { ListAppsDeviceAction } from "./ListAppsDeviceAction"; import { ListAppsDAState } from "./types"; -const BTC_APP = { - appEntryLength: 77, - appSizeInBlocks: 3227, - appCodeHash: - "924b5ba590971b3e98537cf8241f0aa51b1e6f26c37915dd38b83255168255d5", - appFullHash: - "81e73bd232ef9b26c00a152cb291388fb3ded1a2db6b44f53b3119d91d2879bb", - appName: "Bitcoin", -}; -const CUSTOM_LOCK_SCREEN_APP = { - appEntryLength: 70, - appSizeInBlocks: 1093, - appCodeHash: - "0000000000000000000000000000000000000000000000000000000000000000", - appFullHash: - "5602b3d3fdde77fc02eb451a8beec4155bcf8b83ced794d7b3c63afaed5ff8c6", - appName: "", -}; - -const ETH_APP = { - appEntryLength: 78, - appSizeInBlocks: 4120, - appCodeHash: - "4fdb751c0444f3a982c2ae9dcfde6ebe6dab03613d496f5e53cf91bce8ca46b5", - appFullHash: - "c7507c742ce3f8ec446b1ebda18159a5d432241a7199c3fc2401e72adfa9ab38", - appName: "Ethereum", -}; - -const SOLANA_APP = { - appEntryLength: 76, - appSizeInBlocks: 2568, - appCodeHash: - "dcc77e385de4394f579fa7b6eeb7293950fe5aec6d5355a7049f77bc0d02de24", - appFullHash: - "afbdaa67241e21c00191b177198615b50c98e5db998c3bba1d78093a85dbedee", - appName: "Solana", -}; -const DOGECOIN_APP = { - appEntryLength: 78, - appSizeInBlocks: 2458, - appCodeHash: - "e59eee7bd32b1af2d93c5d8211e33d844d153a710d800254ea754e10ce18e7a9", - appFullHash: - "227001130f66297406696a19e1cf1e8e8b0cc14d5824ae8b1da98122c322e22e", - appName: "Dogecoin", -}; - jest.mock("@api/device-action/os/GoToDashboard/GoToDashboardDeviceAction"); -const setupGoToDashboardMock = (error: boolean = false) => { - (GoToDashboardDeviceAction as jest.Mock).mockImplementation(() => ({ - makeStateMachine: jest.fn().mockImplementation(() => - createMachine({ - id: "MockGoToDashboardDeviceAction", - initial: "ready", - states: { - ready: { - after: { - 0: "done", - }, - entry: assign({ - intermediateValue: () => ({ - requiredUserInteraction: UserInteractionRequired.None, - }), - }), - }, - done: { - type: "final", - }, - }, - output: () => { - return error - ? Left(new UnknownDAError("GoToDashboard failed")) - : Right(undefined); - }, - }), - ), - })); -}; - describe("ListAppsDeviceAction", () => { - const sendCommandMock = jest.fn(); - const apiGetDeviceSessionStateMock = jest.fn(); - const apiGetDeviceSessionStateObservableMock = jest.fn(); - const setDeviceSessionStateMock = jest.fn(); - - function internalApiMock(): InternalApi { - return { - sendCommand: sendCommandMock, - getDeviceSessionState: apiGetDeviceSessionStateMock, - getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock, - setDeviceSessionState: setDeviceSessionStateMock, - }; - } + const { sendCommand: sendCommandMock } = makeDeviceActionInternalApiMock(); beforeEach(() => { jest.resetAllMocks(); @@ -151,7 +65,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -192,7 +106,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -241,7 +155,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -290,7 +204,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -346,7 +260,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -361,7 +275,6 @@ describe("ListAppsDeviceAction", () => { .mockResolvedValueOnce([BTC_APP, CUSTOM_LOCK_SCREEN_APP]) .mockResolvedValueOnce([ETH_APP, SOLANA_APP]) .mockResolvedValueOnce([DOGECOIN_APP]); - const expectedStates: Array = [ { intermediateValue: { @@ -400,7 +313,7 @@ describe("ListAppsDeviceAction", () => { ETH_APP, SOLANA_APP, DOGECOIN_APP, - ], + ] as ListAppsResponse, status: DeviceActionStatus.Completed, // Success }, ]; @@ -408,7 +321,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -445,7 +358,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -480,7 +393,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -521,7 +434,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -570,7 +483,7 @@ describe("ListAppsDeviceAction", () => { testDeviceActionStates( listAppsDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); diff --git a/packages/core/src/api/device-action/os/ListAppsWithMetadata/ListAppsWithMetadataDeviceAction.test.ts b/packages/core/src/api/device-action/os/ListAppsWithMetadata/ListAppsWithMetadataDeviceAction.test.ts new file mode 100644 index 000000000..eb5375ffc --- /dev/null +++ b/packages/core/src/api/device-action/os/ListAppsWithMetadata/ListAppsWithMetadataDeviceAction.test.ts @@ -0,0 +1,423 @@ +import { Left, Right } from "purify-ts"; + +import { DeviceStatus } from "@api/device/DeviceStatus"; +import { + BTC_APP, + BTC_APP_METADATA, + CUSTOM_LOCK_SCREEN_APP, + CUSTOM_LOCK_SCREEN_APP_METADATA, + ETH_APP, + ETH_APP_METADATA, +} from "@api/device-action/__test-utils__/data"; +import { makeDeviceActionInternalApiMock } from "@api/device-action/__test-utils__/makeInternalApi"; +import { setupListAppsMock } from "@api/device-action/__test-utils__/setupTestMachine"; +import { testDeviceActionStates } from "@api/device-action/__test-utils__/testDeviceActionStates"; +import { DeviceActionStatus } from "@api/device-action/model/DeviceActionState"; +import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; +import { UnknownDAError } from "@api/device-action/os/Errors"; +import { DeviceSessionStateType } from "@api/device-session/DeviceSessionState"; +import { HttpFetchApiError } from "@internal/manager-api/model/Errors"; + +import { ListAppsWithMetadataDeviceAction } from "./ListAppsWithMetadataDeviceAction"; +import { ListAppsWithMetadataDAState } from "./types"; + +jest.mock("@api/device-action/os/ListApps/ListAppsDeviceAction"); + +describe("ListAppsWithMetadataDeviceAction", () => { + const { + getMetadataForAppHashes: getMetadataForAppHashesMock, + // getDeviceSessionState: apiGetDeviceSessionStateMock, + // setDeviceSessionState: apiSetDeviceSessionStateMock, + } = makeDeviceActionInternalApiMock(); + + const saveSessionStateMock = jest.fn(); + const getDeviceSessionStateMock = jest.fn(); + const getAppsByHashMock = jest.fn(); + + function extractDependenciesMock() { + return { + getAppsByHash: getAppsByHashMock, + getDeviceSessionState: getDeviceSessionStateMock, + saveSessionState: saveSessionStateMock, + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("success case", () => { + it("should run the device actions with no apps installed", (done) => { + setupListAppsMock([]); + const listAppsWithMetadataDeviceAction = + new ListAppsWithMetadataDeviceAction({ + input: {}, + }); + + getMetadataForAppHashesMock.mockResolvedValue(Right([])); + + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // Ready + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.AllowListApps, + }, + status: DeviceActionStatus.Pending, // ListAppsDeviceAction + }, + { + status: DeviceActionStatus.Completed, + output: [], + }, + ]; + + testDeviceActionStates( + listAppsWithMetadataDeviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should run the device actions with 1 app installed", (done) => { + setupListAppsMock([BTC_APP]); + const listAppsWithMetadataDeviceAction = + new ListAppsWithMetadataDeviceAction({ + input: {}, + }); + + getMetadataForAppHashesMock.mockResolvedValue(Right([BTC_APP_METADATA])); + + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // Ready + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.AllowListApps, + }, + status: DeviceActionStatus.Pending, // ListAppsDeviceAction + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // FetchMetadata + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // SaveSession + }, + { + status: DeviceActionStatus.Completed, + output: [BTC_APP_METADATA], + }, + ]; + + testDeviceActionStates( + listAppsWithMetadataDeviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should run the device actions with 2 apps installed", (done) => { + setupListAppsMock([BTC_APP, ETH_APP]); + const listAppsWithMetadataDeviceAction = + new ListAppsWithMetadataDeviceAction({ + input: {}, + }); + + getMetadataForAppHashesMock.mockResolvedValue( + Right([BTC_APP_METADATA, ETH_APP_METADATA]), + ); + + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // Ready + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.AllowListApps, + }, + status: DeviceActionStatus.Pending, // ListAppsDeviceAction + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // FetchMetadata + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // SaveSession + }, + { + status: DeviceActionStatus.Completed, + output: [BTC_APP_METADATA, ETH_APP_METADATA], + }, + ]; + + testDeviceActionStates( + listAppsWithMetadataDeviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should run the device actions with 1 app installed and a custom lock screen", (done) => { + setupListAppsMock([BTC_APP, CUSTOM_LOCK_SCREEN_APP]); + const listAppsWithMetadataDeviceAction = + new ListAppsWithMetadataDeviceAction({ + input: {}, + }); + + getMetadataForAppHashesMock.mockResolvedValue( + Right([BTC_APP_METADATA, CUSTOM_LOCK_SCREEN_APP_METADATA]), + ); + + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // Ready + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.AllowListApps, + }, + status: DeviceActionStatus.Pending, // ListAppsDeviceAction + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // FetchMetadata + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // SaveSession + }, + { + status: DeviceActionStatus.Completed, + output: [BTC_APP_METADATA, CUSTOM_LOCK_SCREEN_APP_METADATA], + }, + ]; + + testDeviceActionStates( + listAppsWithMetadataDeviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); + + describe("error case", () => { + it("should error when ListApps fails", (done) => { + setupListAppsMock([], true); + const listAppsWithMetadataDeviceAction = + new ListAppsWithMetadataDeviceAction({ + input: {}, + }); + + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // Ready + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.AllowListApps, + }, + status: DeviceActionStatus.Pending, // ListAppsDeviceAction + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDAError("ListApps failed"), + }, + ]; + + testDeviceActionStates( + listAppsWithMetadataDeviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should error when getAppsByHash rejects", (done) => { + setupListAppsMock([BTC_APP]); + const listAppsWithMetadataDeviceAction = + new ListAppsWithMetadataDeviceAction({ + input: {}, + }); + + getMetadataForAppHashesMock.mockRejectedValue( + new UnknownDAError("getAppsByHash failed"), + ); + + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // Ready + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.AllowListApps, + }, + status: DeviceActionStatus.Pending, // ListAppsDeviceAction + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // FetchMetadata + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDAError("getAppsByHash failed"), + }, + ]; + + testDeviceActionStates( + listAppsWithMetadataDeviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should error when getAppsByHash fails but error is known", (done) => { + setupListAppsMock([BTC_APP]); + const listAppsWithMetadataDeviceAction = + new ListAppsWithMetadataDeviceAction({ + input: {}, + }); + + const error = new HttpFetchApiError(new Error("Failed to fetch data")); + + getMetadataForAppHashesMock.mockResolvedValue(Left(error)); + + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // Ready + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.AllowListApps, + }, + status: DeviceActionStatus.Pending, // ListAppsDeviceAction + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // FetchMetadata + }, + { + status: DeviceActionStatus.Error, + error, + }, + ]; + + testDeviceActionStates( + listAppsWithMetadataDeviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should error when SaveSession fails", (done) => { + setupListAppsMock([BTC_APP]); + const listAppsWithMetadataDeviceAction = + new ListAppsWithMetadataDeviceAction({ + input: {}, + }); + + getAppsByHashMock.mockImplementation(async () => + Promise.resolve(Right([BTC_APP_METADATA])), + ); + + jest + .spyOn(listAppsWithMetadataDeviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + + getDeviceSessionStateMock.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + currentApp: "BOLOS", + installedApps: [], + }); + + saveSessionStateMock.mockImplementation(() => { + throw new UnknownDAError("SaveSession failed"); + }); + + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // Ready + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.AllowListApps, + }, + status: DeviceActionStatus.Pending, // ListAppsDeviceAction + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // FetchMetadata + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, // SaveSession + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDAError("SaveSession failed"), + }, + ]; + + testDeviceActionStates( + listAppsWithMetadataDeviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); +}); diff --git a/packages/core/src/api/device-action/os/ListAppsWithMetadata/ListAppsWithMetadataDeviceAction.ts b/packages/core/src/api/device-action/os/ListAppsWithMetadata/ListAppsWithMetadataDeviceAction.ts new file mode 100644 index 000000000..2864bc4ab --- /dev/null +++ b/packages/core/src/api/device-action/os/ListAppsWithMetadata/ListAppsWithMetadataDeviceAction.ts @@ -0,0 +1,292 @@ +import { EitherAsync, Left, Right } from "purify-ts"; +import { + AnyEventObject, + assign, + fromCallback, + fromPromise, + setup, +} from "xstate"; + +import { ListAppsResponse } from "@api/command/os/ListAppsCommand"; +import { InternalApi } from "@api/device-action/DeviceAction"; +import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; +import { DEFAULT_UNLOCK_TIMEOUT_MS } from "@api/device-action/os/Const"; +import { ListAppsDeviceAction } from "@api/device-action/os/ListApps/ListAppsDeviceAction"; +import { ListAppsDAOutput } from "@api/device-action/os/ListApps/types"; +import { StateMachineTypes } from "@api/device-action/xstate-utils/StateMachineTypes"; +import { XStateDeviceAction } from "@api/device-action/xstate-utils/XStateDeviceAction"; +import { DeviceSessionState } from "@api/device-session/DeviceSessionState"; +import { HttpFetchApiError } from "@internal/manager-api/model/Errors"; +import { Application } from "@internal/manager-api/model/ManagerApiType"; + +import { + ListAppsWithMetadataDAError, + ListAppsWithMetadataDAInput, + ListAppsWithMetadataDAIntermediateValue, + ListAppsWithMetadataDAOutput, +} from "./types"; + +type ListAppsWithMetadataMachineInternalState = { + error: ListAppsWithMetadataDAError | null; + apps: ListAppsResponse; + appsWithMetadata: ListAppsWithMetadataDAOutput; +}; + +export type MachineDependencies = { + getAppsByHash: ({ + input, + }: { + input: ListAppsDAOutput; + }) => EitherAsync>; + getDeviceSessionState: () => DeviceSessionState; + saveSessionState: (state: DeviceSessionState) => DeviceSessionState; +}; + +export class ListAppsWithMetadataDeviceAction extends XStateDeviceAction< + ListAppsWithMetadataDAOutput, + ListAppsWithMetadataDAInput, + ListAppsWithMetadataDAError, + ListAppsWithMetadataDAIntermediateValue, + ListAppsWithMetadataMachineInternalState +> { + makeStateMachine(internalAPI: InternalApi) { + type types = StateMachineTypes< + ListAppsWithMetadataDAOutput, + ListAppsWithMetadataDAInput, + ListAppsWithMetadataDAError, + ListAppsWithMetadataDAIntermediateValue, + ListAppsWithMetadataMachineInternalState + >; + + const { getAppsByHash, saveSessionState, getDeviceSessionState } = + this.extractDependencies(internalAPI); + + const unlockTimeout = this.input.unlockTimeout ?? DEFAULT_UNLOCK_TIMEOUT_MS; + + const listAppsMachine = new ListAppsDeviceAction({ + input: { + unlockTimeout, + }, + }).makeStateMachine(internalAPI); + + return setup({ + types: { + input: { + unlockTimeout, + } as types["input"], + context: {} as types["context"], + output: {} as types["output"], + }, + actors: { + listApps: listAppsMachine, + getAppsByHash: fromPromise(getAppsByHash), + saveSessionState: fromCallback( + ({ + input, + sendBack, + }: { + sendBack: (event: AnyEventObject) => void; + input: { + appsWithMetadata: Array; + }; + }) => { + const { appsWithMetadata } = input; + + const filterted = appsWithMetadata.filter((app) => app !== null); + + const sessionState = getDeviceSessionState(); + const updatedState = { + ...sessionState, + installedApps: filterted, + }; + try { + saveSessionState(updatedState); + sendBack({ type: "done" }); + } catch (error) { + sendBack({ type: "error", error }); + } + }, + ), + }, + guards: { + hasError: ({ context }: { context: types["context"] }) => { + return context._internalState.error !== null; + }, + hasNoAppsInstalled: ({ context }: { context: types["context"] }) => + context._internalState.apps.length === 0, + }, + actions: { + assignErrorFromEvent: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + error: _.event["error"], // FIXME: add a typeguard + }), + }), + }, + }).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QBkCWsAuBBADj2A6qhgBYCyYGAhhFdQCJgBuqAxmFqxqgPYB2AOkYt2AJTA0AngGIA2gAYAuolA4esYrz4qQAD0QBGAJzyBADgBsAdgNWAzPIAs8+TceOANCEmILAJjMBKwsHOyMjAzN7SIBfGK80TFx8IlIKaloGZjYOLi0BROw8WGkIfjABVD4mHgBrCoAbdCL8BWUkEDUNbn4dfQQAVgCBIz9IgacQ6z8-Lx8Ef1N5OwsBlbs7dyNHCziE5uTCYnJKGjoqYRzOHsFCw+kwACdHnkeBHAa6ADNXgFsBJpJYptHRdTS9Dr9IaBUbjSYrKwzOaIMwGAQTFx2MxmOx+AYDKxGAZ7EB3YqpE4Zc6XdjXfJk-AAYRIYFYtTkSlB6nB2khKKGAj89jCRjCFmWdmRCD8LiCGIMMysqIsCpJDKOaVOmQu2VpeX4BQOxWZrPZsgM7VU3JufX5fkFwvCYolUqVjkFyxlA3FeO9VjVRpSx3SZyyIlyN0NQKZLLZcj8ls61q0toQZgFQo2TqM4o2UosjjsQRC8iFa1GfiJAejGspoZ14bpBoAYpRWHXtaVypVqnUKjAWrAAEKSAASVFgJBBHTBNr5CAM7gsAkcY3TZmW-iMiPzm0Flkc2PX8gMTmrg4pIe1NIj+VbGHbV-OD2er3enwwP0e-wHhxH48nacrW6FN50XHYVzXAYNxWSsd28RA7GMQUMUcaCLFFTZdniUlA1rJ8wyufVBHvR8tXOE04yApMQIhUB+kiVYBFLTYbBmPwCwsKUdjRRxghVMwAlWSIjHPQ5L3Iwi9UjUiOwo2MzQtLlaN5ejDEsAZmL8ViFRmTj8wLSDt3kbFtzGZYxPJYNJIbIjIwAZSoJgwHsuANH4Ls+DAajZ1AtTpQ4wJnH8UyHB9fNgiMjCzCdcUzEcSyg01KkpNvA1HOc1zYHcvgXxeR4fOTOi9EQPSgvkEKYrC0spQJIwRn4pw8QLAwQjiHC+B4CA4B0dUJJS2zpL8mieVTABaLiEIQMbNPCOb5vmyxEvwmybybQQb3EKRlNG+dV3zIVizCxwc3sPxlv6+s1uIqNBx2ud-ICWa1lPexsWMb18ycQUS3FfEFTsb0LusgbrsjdVKNqe7hv6CwogECwCxPQGhSsNCjHzVdmIMMKBgMBV8eMYHkqu3U0pIts5OoaHioY093QCEwMNFJV5Gg-NInRHGKoqnNSzR4mqcG8mBFkgiqEhmnVJKhdVndQTlncKxXCsfF829H6HFsEJvSFbD9hrS7rzJ9aBAyly3OG3zadK-nzAGUUzKsQlxQxqaQnq2xt1XNnnBMuxBfFsH8nsgBXVh2GyqXUzxCqBEB7YxlXGLIisV0cy5sLlcR53RUD1aTZugBRV9Hmj+dY+XBPV0XRnU9qyxzGiiqNwmOxgnamIgA */ + id: "ListAppsWithMetadataDeviceAction", + initial: "DeviceReady", + context: (_) => { + return { + input: _.input, + _internalState: { + error: null, + apps: [], + appsWithMetadata: [], + }, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }; + }, + states: { + DeviceReady: { + always: { + target: "ListApps", + }, + }, + ListApps: { + invoke: { + id: "listApps", + src: "listApps", + input: (_) => ({ + unlockTimeout: _.context.input.unlockTimeout, + }), + onSnapshot: { + actions: assign({ + intermediateValue: (_) => + _.event.snapshot.context.intermediateValue, + }), + }, + onDone: { + target: "ListAppsCheck", + actions: assign({ + intermediateValue: (_) => ({ + requiredUserInteraction: UserInteractionRequired.None, + }), + _internalState: (_) => { + return _.event.output.caseOf({ + Right: (apps) => ({ + ..._.context._internalState, + apps, + }), + Left: (error) => ({ + ..._.context._internalState, + error, + }), + }); + }, + }), + }, + onError: { + target: "Error", + entry: "assignErrorFromEvent", + }, + }, + }, + ListAppsCheck: { + always: [ + { + target: "Error", + guard: "hasError", + }, + { + target: "Success", + guard: "hasNoAppsInstalled", + actions: assign({ + _internalState: (_) => { + return { + ..._.context._internalState, + appsWithMetadata: [], + }; + }, + }), + }, + { + target: "FetchMetadata", + }, + ], + }, + FetchMetadata: { + invoke: { + id: "getAppsByHash", + src: "getAppsByHash", + input: (_) => _.context._internalState.apps, + onDone: { + target: "FetchMetadataCheck", + actions: assign({ + _internalState: (_) => { + return _.event.output.caseOf({ + Right: (appsWithMetadata) => ({ + ..._.context._internalState, + appsWithMetadata, + }), + Left: (error) => ({ + ..._.context._internalState, + error, + }), + }); + }, + }), + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + FetchMetadataCheck: { + always: [ + { + target: "Error", + guard: "hasError", + }, + { + target: "SaveSession", + }, + ], + }, + SaveSession: { + invoke: { + src: "saveSessionState", + input: (_) => ({ + appsWithMetadata: _.context._internalState.appsWithMetadata, + }), + }, + on: { + done: { + target: "Success", + }, + error: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + Success: { + type: "final", + }, + Error: { + type: "final", + }, + }, + output: (_) => { + if (_.context._internalState.error) { + return Left(_.context._internalState.error); + } + + return Right(_.context._internalState.appsWithMetadata); + }, + }); + } + + extractDependencies(internalApi: InternalApi): MachineDependencies { + return { + getAppsByHash: ({ input }) => internalApi.getMetadataForAppHashes(input), + getDeviceSessionState: () => internalApi.getDeviceSessionState(), + saveSessionState: (state: DeviceSessionState) => + internalApi.setDeviceSessionState(state), + }; + } +} diff --git a/packages/core/src/api/device-action/os/ListAppsWithMetadata/types.ts b/packages/core/src/api/device-action/os/ListAppsWithMetadata/types.ts new file mode 100644 index 000000000..4181677ac --- /dev/null +++ b/packages/core/src/api/device-action/os/ListAppsWithMetadata/types.ts @@ -0,0 +1,33 @@ +import { DeviceActionState } from "@api/device-action/model/DeviceActionState"; +import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; +import { UnknownDAError } from "@api/device-action/os/Errors"; +import { + ListAppsDAError, + ListAppsDAInput, + ListAppsDAIntermediateValue, +} from "@api/device-action/os/ListApps/types"; +import { SdkError } from "@api/Error"; +import { Application } from "@internal/manager-api/model/ManagerApiType"; + +export type ListAppsWithMetadataDAOutput = Array; +export type ListAppsWithMetadataDAInput = ListAppsDAInput; + +export type ListAppsWithMetadataDAError = + | ListAppsDAError + | UnknownDAError + | SdkError; /// TODO: remove, we should have an exhaustive list of errors + +export type ListAppsWithMetadataDARequiredInteraction = + UserInteractionRequired.None; + +export type ListAppsWithMetadataDAIntermediateValue = + | ListAppsDAIntermediateValue + | { + requiredUserInteraction: ListAppsWithMetadataDARequiredInteraction; + }; + +export type ListAppsWithMetadataDAState = DeviceActionState< + ListAppsWithMetadataDAOutput, + ListAppsWithMetadataDAError, + ListAppsWithMetadataDAIntermediateValue +>; diff --git a/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.test.ts b/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.test.ts index 2712ef944..d939f1c3f 100644 --- a/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.test.ts +++ b/packages/core/src/api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction.test.ts @@ -1,7 +1,7 @@ import { InvalidStatusWordError } from "@api/command/Errors"; import { DeviceStatus } from "@api/device/DeviceStatus"; +import { makeDeviceActionInternalApiMock } from "@api/device-action/__test-utils__/makeInternalApi"; import { testDeviceActionStates } from "@api/device-action/__test-utils__/testDeviceActionStates"; -import { InternalApi } from "@api/device-action/DeviceAction"; import { DeviceActionStatus } from "@api/device-action/model/DeviceActionState"; import { UserInteractionRequired } from "@api/device-action/model/UserInteractionRequired"; import { @@ -30,19 +30,10 @@ describe("OpenAppDeviceAction", () => { }; } - const sendCommandMock = jest.fn(); - const apiGetDeviceSessionStateMock = jest.fn(); - const apiGetDeviceSessionStateObservableMock = jest.fn(); - const setDeviceSessionStateMock = jest.fn(); - - function internalApiMock(): InternalApi { - return { - sendCommand: sendCommandMock, - getDeviceSessionState: apiGetDeviceSessionStateMock, - getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock, - setDeviceSessionState: setDeviceSessionStateMock, - }; - } + const { + sendCommand: sendCommandMock, + getDeviceSessionState: apiGetDeviceSessionStateMock, + } = makeDeviceActionInternalApiMock(); beforeEach(() => { jest.resetAllMocks(); @@ -55,6 +46,7 @@ describe("OpenAppDeviceAction", () => { sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: DeviceStatus.CONNECTED, currentApp: "Bitcoin", + installedApps: [], }); sendCommandMock.mockResolvedValueOnce({ @@ -82,7 +74,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -123,7 +115,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -169,7 +161,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -221,7 +213,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -254,7 +246,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -284,7 +276,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -324,7 +316,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -369,7 +361,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -417,7 +409,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -470,7 +462,7 @@ describe("OpenAppDeviceAction", () => { testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -509,7 +501,7 @@ describe("OpenAppDeviceAction", () => { const { cancel } = testDeviceActionStates( openAppDeviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); cancel(); diff --git a/packages/core/src/api/device-action/os/SendCommandInAppDeviceAction/SendCommandInAppDeviceAction.test.ts b/packages/core/src/api/device-action/os/SendCommandInAppDeviceAction/SendCommandInAppDeviceAction.test.ts index 662864da7..f9992931f 100644 --- a/packages/core/src/api/device-action/os/SendCommandInAppDeviceAction/SendCommandInAppDeviceAction.test.ts +++ b/packages/core/src/api/device-action/os/SendCommandInAppDeviceAction/SendCommandInAppDeviceAction.test.ts @@ -3,8 +3,8 @@ import { assign, createMachine } from "xstate"; import { Apdu } from "@api/apdu/model/Apdu"; import { ApduBuilder } from "@api/apdu/utils/ApduBuilder"; +import { makeDeviceActionInternalApiMock } from "@api/device-action/__test-utils__/makeInternalApi"; import { testDeviceActionStates } from "@api/device-action/__test-utils__/testDeviceActionStates"; -import { InternalApi } from "@api/device-action/DeviceAction"; import { DeviceActionState, DeviceActionStatus, @@ -67,15 +67,7 @@ describe("SendCommandInAppDeviceAction", () => { sendCommand: sendMyCommand, }); - const apiSendCommandMock = jest.fn(); - const apiGetDeviceSessionStateMock = jest.fn(); - - const internalApiMock = (): InternalApi => ({ - sendCommand: apiSendCommandMock, - getDeviceSessionState: apiGetDeviceSessionStateMock, - getDeviceSessionStateObservable: jest.fn(), - setDeviceSessionState: jest.fn(), - }); + const { sendCommand: apiSendCommandMock } = makeDeviceActionInternalApiMock(); const commandParams = { paramString: "aParameter", @@ -102,11 +94,13 @@ describe("SendCommandInAppDeviceAction", () => { }, }); await new Promise((resolve, reject) => { - deviceAction._execute(internalApiMock()).observable.subscribe({ - error: () => reject(), - complete: () => resolve(), - next: () => {}, - }); + deviceAction + ._execute(makeDeviceActionInternalApiMock()) + .observable.subscribe({ + error: () => reject(), + complete: () => resolve(), + next: () => {}, + }); }); expect(apiSendCommandMock).toHaveBeenCalledWith( @@ -147,7 +141,7 @@ describe("SendCommandInAppDeviceAction", () => { }, }), expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -197,7 +191,7 @@ describe("SendCommandInAppDeviceAction", () => { testDeviceActionStates( deviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); @@ -249,7 +243,7 @@ describe("SendCommandInAppDeviceAction", () => { testDeviceActionStates( deviceAction, expectedStates, - internalApiMock(), + makeDeviceActionInternalApiMock(), done, ); }); diff --git a/packages/core/src/api/device-session/DeviceSessionState.ts b/packages/core/src/api/device-session/DeviceSessionState.ts index ed047a70f..d4843b62e 100644 --- a/packages/core/src/api/device-session/DeviceSessionState.ts +++ b/packages/core/src/api/device-session/DeviceSessionState.ts @@ -1,5 +1,6 @@ import { BatteryStatusFlags } from "@api/command/os/GetBatteryStatusCommand"; import { DeviceStatus } from "@api/device/DeviceStatus"; +import { Application } from "@internal/manager-api/model/ManagerApiType"; /** * The battery status of a device. @@ -72,6 +73,11 @@ type DeviceSessionReadyState = { * The current application running on the device. */ readonly currentApp: string; + + /** + * The current applications installed on the device. + */ + readonly installedApps: Application[]; }; /** diff --git a/packages/core/src/api/device/DeviceModel.ts b/packages/core/src/api/device/DeviceModel.ts index d17ba56e1..c2fefbf03 100644 --- a/packages/core/src/api/device/DeviceModel.ts +++ b/packages/core/src/api/device/DeviceModel.ts @@ -3,6 +3,7 @@ export enum DeviceModelId { NANO_SP = "nanoSP", NANO_X = "nanoX", STAX = "stax", + FLEX = "flex", } /** diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index b72d1afce..b0df795e7 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -69,6 +69,14 @@ export { type ListAppsDAOutput, type ListAppsDAState, } from "@api/device-action/os/ListApps/types"; +export { ListAppsWithMetadataDeviceAction } from "@api/device-action/os/ListAppsWithMetadata/ListAppsWithMetadataDeviceAction"; +export { + type ListAppsWithMetadataDAError, + type ListAppsWithMetadataDAInput, + type ListAppsWithMetadataDAIntermediateValue, + type ListAppsWithMetadataDAOutput, + type ListAppsWithMetadataDAState, +} from "@api/device-action/os/ListAppsWithMetadata/types"; export { OpenAppDeviceAction } from "@api/device-action/os/OpenAppDeviceAction/OpenAppDeviceAction"; export { type OpenAppDAError, @@ -88,3 +96,4 @@ export { type StateMachineTypes } from "@api/device-action/xstate-utils/StateMac export { XStateDeviceAction } from "@api/device-action/xstate-utils/XStateDeviceAction"; export { type DeviceSessionState } from "@api/device-session/DeviceSessionState"; export { type SdkError } from "@api/Error"; +export { hexaStringToBuffer, isHexaString } from "@api/utils/HexaString"; diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts index ffe2b0888..6cd0a18aa 100644 --- a/packages/core/src/api/types.ts +++ b/packages/core/src/api/types.ts @@ -9,6 +9,7 @@ export type { SendCommandUseCaseArgs } from "@api/command/use-case/SendCommandUs export type { DeviceModelId } from "@api/device/DeviceModel"; export type { ExecuteDeviceActionUseCaseArgs } from "@api/device-action/use-case/ExecuteDeviceActionUseCase"; export type { DeviceSessionId } from "@api/device-session/types"; +export type { HexaString } from "@api/utils/HexaString"; export type { ConnectUseCaseArgs } from "@internal/discovery/use-case/ConnectUseCase"; export type { DisconnectUseCaseArgs } from "@internal/discovery/use-case/DisconnectUseCase"; export type { SendApduUseCaseArgs } from "@internal/send/use-case/SendApduUseCase"; diff --git a/packages/core/src/api/utils/HexaString.test.ts b/packages/core/src/api/utils/HexaString.test.ts new file mode 100644 index 000000000..1606ea437 --- /dev/null +++ b/packages/core/src/api/utils/HexaString.test.ts @@ -0,0 +1,130 @@ +import { hexaStringToBuffer, isHexaString } from "./HexaString"; + +describe("HexaString", () => { + describe("isHexaString function", () => { + it("should return true if the value is a valid hex string", () => { + // GIVEN + const value = "0x1234abc"; + + // WHEN + const result = isHexaString(value); + + // THEN + expect(result).toBeTruthy(); + }); + + it("should return true if no data", () => { + // GIVEN + const value = "0x"; + + // WHEN + const result = isHexaString(value); + + // THEN + expect(result).toBeTruthy(); + }); + + it("should return false if the value contain an invalid letter", () => { + // GIVEN + const value = "0x1234z"; + + // WHEN + const result = isHexaString(value); + + // THEN + expect(result).toBeFalsy(); + }); + + it("should return false if the value does not start with 0x", () => { + // GIVEN + const value = "1234abc"; + + // WHEN + const result = isHexaString(value); + + // THEN + expect(result).toBeFalsy(); + }); + + it("should return false for an epmty string", () => { + // GIVEN + const value = ""; + + // WHEN + const result = isHexaString(value); + + // THEN + expect(result).toBeFalsy(); + }); + }); + + describe("hexaStringToBuffer function", () => { + it("should fail on empty input", () => { + // GIVEN + const value = ""; + + // WHEN + const result = hexaStringToBuffer(value); + + // THEN + expect(result).toStrictEqual(null); + }); + + it("should fail on invalid string", () => { + // GIVEN + const value = "bonjour"; + + // WHEN + const result = hexaStringToBuffer(value); + + // THEN + expect(result).toStrictEqual(null); + }); + + it("should convert correct hexadecimal string", () => { + // GIVEN + const value = "1a35669f0100"; + + // WHEN + const result = hexaStringToBuffer(value); + + // THEN + expect(result).toStrictEqual( + new Uint8Array([0x1a, 0x35, 0x66, 0x9f, 0x01, 0x00]), + ); + }); + + it("should support 0x prefix", () => { + // GIVEN + const value = "0x1a35"; + + // WHEN + const result = hexaStringToBuffer(value); + + // THEN + expect(result).toStrictEqual(new Uint8Array([0x1a, 0x35])); + }); + + it("should be case insensitive", () => { + // GIVEN + const value = "0xcCDd"; + + // WHEN + const result = hexaStringToBuffer(value); + + // THEN + expect(result).toStrictEqual(new Uint8Array([0xcc, 0xdd])); + }); + + it("should pad with 0", () => { + // GIVEN + const value = "0xa35"; + + // WHEN + const result = hexaStringToBuffer(value); + + // THEN + expect(result).toStrictEqual(new Uint8Array([0x0a, 0x35])); + }); + }); +}); diff --git a/packages/core/src/api/utils/HexaString.ts b/packages/core/src/api/utils/HexaString.ts new file mode 100644 index 000000000..c047fecc5 --- /dev/null +++ b/packages/core/src/api/utils/HexaString.ts @@ -0,0 +1,22 @@ +export type HexaString = `0x${string}`; + +export const isHexaString = (value: string): value is HexaString => { + return /^0x[0-9a-fA-F]*$/.test(value); +}; + +export const hexaStringToBuffer = (value: string): Uint8Array | null => { + if (value.length === 0) { + return null; + } + if (value.startsWith("0x")) { + value = value.slice(2); + } + if (value.length % 2 !== 0) { + value = "0" + value; + } + const bytes = value.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)); + if (!bytes || bytes.some(isNaN)) { + return null; + } + return new Uint8Array(bytes); +}; diff --git a/packages/core/src/di.ts b/packages/core/src/di.ts index a5516674d..25e5595c5 100644 --- a/packages/core/src/di.ts +++ b/packages/core/src/di.ts @@ -1,15 +1,18 @@ import { Container } from "inversify"; +// Uncomment this line to enable the logger middleware +// import { makeLoggerMiddleware } from "inversify-logger-middleware"; import { commandModuleFactory } from "@api/command/di/commandModule"; import { deviceActionModuleFactory } from "@api/device-action/di/deviceActionModule"; import { LoggerSubscriberService } from "@api/logger-subscriber/service/LoggerSubscriberService"; -// Uncomment this line to enable the logger middleware -// import { makeLoggerMiddleware } from "inversify-logger-middleware"; +import { SdkConfig } from "@api/SdkConfig"; import { configModuleFactory } from "@internal/config/di/configModule"; import { deviceModelModuleFactory } from "@internal/device-model/di/deviceModelModule"; import { deviceSessionModuleFactory } from "@internal/device-session/di/deviceSessionModule"; import { discoveryModuleFactory } from "@internal/discovery/di/discoveryModule"; import { loggerModuleFactory } from "@internal/logger-publisher/di/loggerModule"; +import { managerApiModuleFactory } from "@internal/manager-api/di/managerApiModule"; +import { DEFAULT_MANAGER_API_BASE_URL } from "@internal/manager-api/model/Const"; import { sendModuleFactory } from "@internal/send/di/sendModule"; import { usbModuleFactory } from "@internal/usb/di/usbModule"; @@ -17,14 +20,16 @@ import { usbModuleFactory } from "@internal/usb/di/usbModule"; // const logger = makeLoggerMiddleware(); export type MakeContainerProps = { - stub: boolean; - loggers: LoggerSubscriberService[]; + stub?: boolean; + loggers?: LoggerSubscriberService[]; + config: SdkConfig; }; export const makeContainer = ({ stub = false, loggers = [], -}: Partial) => { + config = { managerApiUrl: DEFAULT_MANAGER_API_BASE_URL }, +}: MakeContainerProps) => { const container = new Container(); // Uncomment this line to enable the logger middleware @@ -34,6 +39,7 @@ export const makeContainer = ({ configModuleFactory({ stub }), deviceModelModuleFactory({ stub }), usbModuleFactory({ stub }), + managerApiModuleFactory({ stub, config }), discoveryModuleFactory({ stub }), loggerModuleFactory({ subscribers: loggers }), deviceSessionModuleFactory({ stub }), diff --git a/packages/core/src/internal/config/di/configModule.test.ts b/packages/core/src/internal/config/di/configModule.test.ts index 2633454ad..8b5d25c0c 100644 --- a/packages/core/src/internal/config/di/configModule.test.ts +++ b/packages/core/src/internal/config/di/configModule.test.ts @@ -13,7 +13,7 @@ describe("configModuleFactory", () => { let container: Container; let mod: ReturnType; beforeEach(() => { - mod = configModuleFactory(); + mod = configModuleFactory({ stub: false }); container = new Container(); container.load(mod); }); diff --git a/packages/core/src/internal/config/di/configModule.ts b/packages/core/src/internal/config/di/configModule.ts index 762c9910d..e749a2a52 100644 --- a/packages/core/src/internal/config/di/configModule.ts +++ b/packages/core/src/internal/config/di/configModule.ts @@ -15,9 +15,7 @@ type FactoryProps = { stub: boolean; }; -export const configModuleFactory = ({ - stub = false, -}: Partial = {}) => +export const configModuleFactory = ({ stub }: FactoryProps) => new ContainerModule((bind, _unbind, _isBound, rebind) => { bind(configTypes.LocalConfigDataSource).to(FileLocalConfigDataSource); bind(configTypes.RemoteConfigDataSource).to(RestRemoteConfigDataSource); diff --git a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts index d3d1c76e5..360c7cf36 100644 --- a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts +++ b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.test.ts @@ -9,7 +9,7 @@ describe("StaticDeviceModelDataSource", () => { const deviceModels = dataSource.getAllDeviceModels(); // Currently supporting 4 device models - expect(deviceModels.length).toEqual(4); + expect(deviceModels.length).toEqual(5); expect(deviceModels).toContainEqual( expect.objectContaining({ id: DeviceModelId.NANO_S }), ); @@ -22,6 +22,9 @@ describe("StaticDeviceModelDataSource", () => { expect(deviceModels).toContainEqual( expect.objectContaining({ id: DeviceModelId.STAX }), ); + expect(deviceModels).toContainEqual( + expect.objectContaining({ id: DeviceModelId.FLEX }), + ); }); }); @@ -56,6 +59,13 @@ describe("StaticDeviceModelDataSource", () => { expect(deviceModel4).toEqual( expect.objectContaining({ id: DeviceModelId.STAX }), ); + + const deviceModel5 = dataSource.getDeviceModel({ + id: DeviceModelId.FLEX, + }); + expect(deviceModel5).toEqual( + expect.objectContaining({ id: DeviceModelId.FLEX }), + ); }); }); diff --git a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts index b02c8fc70..d29f406b7 100644 --- a/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts +++ b/packages/core/src/internal/device-model/data/StaticDeviceModelDataSource.ts @@ -65,6 +65,23 @@ export class StaticDeviceModelDataSource implements DeviceModelDataSource { }, ], }), + [DeviceModelId.FLEX]: new InternalDeviceModel({ + id: DeviceModelId.FLEX, + productName: "Ledger Flex", + usbProductId: 0x70, + legacyUsbProductId: 0x0007, + usbOnly: false, + memorySize: 1533 * 1024, + masks: [0x33300000], + bluetoothSpec: [ + { + serviceUuid: "13d63400-2c97-3004-0000-4c6564676572", + notifyUuid: "13d63400-2c97-3004-0001-4c6564676572", + writeUuid: "13d63400-2c97-3004-0002-4c6564676572", + writeCmdUuid: "13d63400-2c97-3004-0003-4c6564676572", + }, + ], + }), }; getAllDeviceModels(): InternalDeviceModel[] { diff --git a/packages/core/src/internal/device-model/di/deviceModelModule.test.ts b/packages/core/src/internal/device-model/di/deviceModelModule.test.ts index e3d149431..86301bc64 100644 --- a/packages/core/src/internal/device-model/di/deviceModelModule.test.ts +++ b/packages/core/src/internal/device-model/di/deviceModelModule.test.ts @@ -9,7 +9,7 @@ describe("deviceModelModuleFactory", () => { let container: Container; let mod: ReturnType; beforeEach(() => { - mod = deviceModelModuleFactory(); + mod = deviceModelModuleFactory({ stub: false }); container = new Container(); container.load(mod); }); diff --git a/packages/core/src/internal/device-model/di/deviceModelModule.ts b/packages/core/src/internal/device-model/di/deviceModelModule.ts index f41abd4ca..2d9d6ffed 100644 --- a/packages/core/src/internal/device-model/di/deviceModelModule.ts +++ b/packages/core/src/internal/device-model/di/deviceModelModule.ts @@ -8,9 +8,7 @@ type FactoryProps = { stub: boolean; }; -export const deviceModelModuleFactory = ({ - stub = false, -}: Partial = {}) => +export const deviceModelModuleFactory = ({ stub }: FactoryProps) => new ContainerModule((bind, _unbind, _isBound, _rebind) => { bind(deviceModelTypes.DeviceModelDataSource).to( StaticDeviceModelDataSource, diff --git a/packages/core/src/internal/device-model/model/DeviceModel.test.ts b/packages/core/src/internal/device-model/model/DeviceModel.test.ts index 457011d4d..323420f8d 100644 --- a/packages/core/src/internal/device-model/model/DeviceModel.test.ts +++ b/packages/core/src/internal/device-model/model/DeviceModel.test.ts @@ -56,4 +56,15 @@ describe("DeviceModel", () => { expect(deviceModel.getBlockSize(firmwareVersion)).toBe(2 * 1024); }); + + // flex + test("should return the correct block size for Flex", () => { + const deviceModel = new InternalDeviceModel({ + ...stubDeviceModel, + id: DeviceModelId.FLEX, + }); + const firmwareVersion = "2.0.0"; + + expect(deviceModel.getBlockSize(firmwareVersion)).toBe(32); + }); }); diff --git a/packages/core/src/internal/device-model/model/DeviceModel.ts b/packages/core/src/internal/device-model/model/DeviceModel.ts index 96dc07cc0..b1019de32 100644 --- a/packages/core/src/internal/device-model/model/DeviceModel.ts +++ b/packages/core/src/internal/device-model/model/DeviceModel.ts @@ -52,11 +52,11 @@ export class InternalDeviceModel { return semver.lt(semver.coerce(firmwareVersion) ?? "", "2.0.0") ? 4 * 1024 : 2 * 1024; - case DeviceModelId.NANO_SP: - return 32; case DeviceModelId.NANO_X: return 4 * 1024; + case DeviceModelId.NANO_SP: case DeviceModelId.STAX: + case DeviceModelId.FLEX: return 32; } } diff --git a/packages/core/src/internal/device-session/model/DeviceSession.stub.ts b/packages/core/src/internal/device-session/model/DeviceSession.stub.ts index bc9f111a9..287744c8e 100644 --- a/packages/core/src/internal/device-session/model/DeviceSession.stub.ts +++ b/packages/core/src/internal/device-session/model/DeviceSession.stub.ts @@ -3,11 +3,13 @@ import { SessionConstructorArgs, } from "@internal/device-session/model/DeviceSession"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import type { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; export const deviceSessionStubBuilder = ( props: Partial = {}, loggerFactory: (tag: string) => LoggerPublisherService, + managerApi: ManagerApiService, ) => new DeviceSession( { @@ -16,4 +18,5 @@ export const deviceSessionStubBuilder = ( ...props, }, loggerFactory, + managerApi, ); diff --git a/packages/core/src/internal/device-session/model/DeviceSession.ts b/packages/core/src/internal/device-session/model/DeviceSession.ts index f8fb2a607..1b83f36e0 100644 --- a/packages/core/src/internal/device-session/model/DeviceSession.ts +++ b/packages/core/src/internal/device-session/model/DeviceSession.ts @@ -1,8 +1,8 @@ -import { inject } from "inversify"; import { BehaviorSubject } from "rxjs"; import { v4 as uuidv4 } from "uuid"; import { Command } from "@api/command/Command"; +import { ListAppsResponse } from "@api/command/os/ListAppsCommand"; import { CommandUtils } from "@api/command/utils/CommandUtils"; import { DeviceStatus } from "@api/device/DeviceStatus"; import { @@ -16,8 +16,8 @@ import { } from "@api/device-session/DeviceSessionState"; import { DeviceSessionId } from "@api/device-session/types"; import { SdkError } from "@api/Error"; -import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { type ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice"; import { DeviceSessionRefresher } from "./DeviceSessionRefresher"; @@ -35,11 +35,12 @@ export class DeviceSession { private readonly _connectedDevice: InternalConnectedDevice; private readonly _deviceState: BehaviorSubject; private readonly _refresher: DeviceSessionRefresher; + private readonly _managerApiService: ManagerApiService; constructor( { connectedDevice, id = uuidv4() }: SessionConstructorArgs, - @inject(loggerTypes.LoggerPublisherServiceFactory) loggerModuleFactory: (tag: string) => LoggerPublisherService, + managerApiService: ManagerApiService, ) { this._id = id; this._connectedDevice = connectedDevice; @@ -56,11 +57,14 @@ export class DeviceSession { isPolling: true, triggersDisconnection: false, }), - updateStateFn: (state: DeviceSessionState) => - this.setDeviceSessionState(state), + updateStateFn: (callback) => { + const state = this._deviceState.getValue(); + this.setDeviceSessionState(callback(state)); + }, }, loggerModuleFactory("device-session-refresher"), ); + this._managerApiService = managerApiService; } public get id() { @@ -146,6 +150,8 @@ export class DeviceSession { this.setDeviceSessionState(state); return this._deviceState.getValue(); }, + getMetadataForAppHashes: (apps: ListAppsResponse) => + this._managerApiService.getAppsByHash(apps), }); return { diff --git a/packages/core/src/internal/device-session/model/DeviceSessionRefresher.ts b/packages/core/src/internal/device-session/model/DeviceSessionRefresher.ts index b31e98deb..7d3243c2c 100644 --- a/packages/core/src/internal/device-session/model/DeviceSessionRefresher.ts +++ b/packages/core/src/internal/device-session/model/DeviceSessionRefresher.ts @@ -37,9 +37,12 @@ export type DeviceSessionRefresherArgs = { /** * Callback that updates the state of the device session with * polling response. - * @param state - The new state to update to. + * @param callback - A function that will take the previous state and return the new state. + * @returns void */ - updateStateFn(state: DeviceSessionState): void; + updateStateFn( + callback: (state: DeviceSessionState) => DeviceSessionState, + ): void; }; /** @@ -103,11 +106,13 @@ export class DeviceSessionRefresher { return; } // `batteryStatus` and `firmwareVersion` are not available in the polling response. - updateStateFn({ + updateStateFn((state) => ({ + ...state, sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, deviceStatus: this._deviceStatus, currentApp: parsedResponse.name, - }); + installedApps: "installedApps" in state ? state.installedApps : [], + })); }); } diff --git a/packages/core/src/internal/device-session/service/DefaultDeviceSessionService.test.ts b/packages/core/src/internal/device-session/service/DefaultDeviceSessionService.test.ts index d3b1b380d..7f382eef0 100644 --- a/packages/core/src/internal/device-session/service/DefaultDeviceSessionService.test.ts +++ b/packages/core/src/internal/device-session/service/DefaultDeviceSessionService.test.ts @@ -3,25 +3,38 @@ import { Either, Left } from "purify-ts"; import { DeviceSession } from "@internal/device-session/model/DeviceSession"; import { DeviceSessionNotFound } from "@internal/device-session/model/Errors"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import type { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; import { DefaultDeviceSessionService } from "./DefaultDeviceSessionService"; jest.mock("@internal/logger-publisher/service/DefaultLoggerPublisherService"); +jest.mock("@internal/manager-api/data/AxiosManagerApiDataSource"); let sessionService: DefaultDeviceSessionService; let loggerService: DefaultLoggerPublisherService; let deviceSession: DeviceSession; +let managerApi: ManagerApiService; +let managerApiDataSource: ManagerApiDataSource; describe("DefaultDeviceSessionService", () => { beforeEach(() => { jest.restoreAllMocks(); loggerService = new DefaultLoggerPublisherService([], "deviceSession"); sessionService = new DefaultDeviceSessionService(() => loggerService); + managerApiDataSource = new AxiosManagerApiDataSource({ + managerApiUrl: "http://fake.url", + }); + managerApi = new DefaultManagerApiService(managerApiDataSource); + deviceSession = new DeviceSession( { connectedDevice: connectedDeviceStubBuilder(), }, () => loggerService, + managerApi, ); }); diff --git a/packages/core/src/internal/device-session/use-case/GetDeviceSessionStateUseCase.test.ts b/packages/core/src/internal/device-session/use-case/GetDeviceSessionStateUseCase.test.ts index bbaa1423e..d00043301 100644 --- a/packages/core/src/internal/device-session/use-case/GetDeviceSessionStateUseCase.test.ts +++ b/packages/core/src/internal/device-session/use-case/GetDeviceSessionStateUseCase.test.ts @@ -3,11 +3,19 @@ import { DefaultDeviceSessionService } from "@internal/device-session/service/De import { DeviceSessionService } from "@internal/device-session/service/DeviceSessionService"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { GetDeviceSessionStateUseCase } from "./GetDeviceSessionStateUseCase"; +jest.mock("@internal/manager-api/data/AxiosManagerApiDataSource"); + let logger: LoggerPublisherService; let sessionService: DeviceSessionService; +let managerApiDataSource: ManagerApiDataSource; +let managerApi: ManagerApiService; const fakeSessionId = "fakeSessionId"; @@ -17,6 +25,10 @@ describe("GetDeviceSessionStateUseCase", () => { [], "get-connected-device-use-case-test", ); + managerApiDataSource = new AxiosManagerApiDataSource({ + managerApiUrl: "http://fake.url", + }); + managerApi = new DefaultManagerApiService(managerApiDataSource); sessionService = new DefaultDeviceSessionService(() => logger); }); @@ -25,6 +37,7 @@ describe("GetDeviceSessionStateUseCase", () => { const deviceSession = deviceSessionStubBuilder( { id: fakeSessionId }, () => logger, + managerApi, ); sessionService.addDeviceSession(deviceSession); const useCase = new GetDeviceSessionStateUseCase( diff --git a/packages/core/src/internal/discovery/di/discoveryModule.test.ts b/packages/core/src/internal/discovery/di/discoveryModule.test.ts index 010f99d91..a0c7b5575 100644 --- a/packages/core/src/internal/discovery/di/discoveryModule.test.ts +++ b/packages/core/src/internal/discovery/di/discoveryModule.test.ts @@ -3,9 +3,11 @@ import { Container } from "inversify"; import { deviceModelModuleFactory } from "@internal/device-model/di/deviceModelModule"; import { deviceSessionModuleFactory } from "@internal/device-session/di/deviceSessionModule"; import { ConnectUseCase } from "@internal/discovery/use-case/ConnectUseCase"; +import { DisconnectUseCase } from "@internal/discovery/use-case/DisconnectUseCase"; import { StartDiscoveringUseCase } from "@internal/discovery/use-case/StartDiscoveringUseCase"; import { StopDiscoveringUseCase } from "@internal/discovery/use-case/StopDiscoveringUseCase"; import { loggerModuleFactory } from "@internal/logger-publisher/di/loggerModule"; +import { managerApiModuleFactory } from "@internal/manager-api/di/managerApiModule"; import { usbModuleFactory } from "@internal/usb/di/usbModule"; import { discoveryModuleFactory } from "./discoveryModule"; @@ -15,15 +17,18 @@ describe("discoveryModuleFactory", () => { let container: Container; let mod: ReturnType; beforeEach(() => { - mod = discoveryModuleFactory(); + mod = discoveryModuleFactory({ stub: false }); container = new Container(); container.load( mod, // The following modules are injected into discovery module loggerModuleFactory(), - usbModuleFactory(), - deviceModelModuleFactory(), + usbModuleFactory({ stub: false }), + deviceModelModuleFactory({ stub: false }), deviceSessionModuleFactory(), + managerApiModuleFactory({ + config: { managerApiUrl: "http://fake.url" }, + }), ); }); @@ -42,6 +47,9 @@ describe("discoveryModuleFactory", () => { ); expect(stopDiscoveringUseCase).toBeInstanceOf(StopDiscoveringUseCase); + const disconnectUseCase = container.get(discoveryTypes.DisconnectUseCase); + expect(disconnectUseCase).toBeInstanceOf(DisconnectUseCase); + const connectUseCase = container.get(discoveryTypes.ConnectUseCase); expect(connectUseCase).toBeInstanceOf(ConnectUseCase); }); diff --git a/packages/core/src/internal/discovery/di/discoveryModule.ts b/packages/core/src/internal/discovery/di/discoveryModule.ts index d2d80bd8b..e2beda61b 100644 --- a/packages/core/src/internal/discovery/di/discoveryModule.ts +++ b/packages/core/src/internal/discovery/di/discoveryModule.ts @@ -12,14 +12,12 @@ type FactoryProps = { stub: boolean; }; -export const discoveryModuleFactory = ({ - stub = false, -}: Partial = {}) => +export const discoveryModuleFactory = ({ stub = false }: FactoryProps) => new ContainerModule((bind, _unbind, _isBound, rebind) => { - bind(discoveryTypes.StartDiscoveringUseCase).to(StartDiscoveringUseCase); - bind(discoveryTypes.StopDiscoveringUseCase).to(StopDiscoveringUseCase); bind(discoveryTypes.ConnectUseCase).to(ConnectUseCase); bind(discoveryTypes.DisconnectUseCase).to(DisconnectUseCase); + bind(discoveryTypes.StartDiscoveringUseCase).to(StartDiscoveringUseCase); + bind(discoveryTypes.StopDiscoveringUseCase).to(StopDiscoveringUseCase); if (stub) { rebind(discoveryTypes.StartDiscoveringUseCase).to(StubUseCase); diff --git a/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts b/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts index ef6952f4c..62cf8394c 100644 --- a/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts +++ b/packages/core/src/internal/discovery/use-case/ConnectUseCase.test.ts @@ -7,6 +7,10 @@ import { DefaultDeviceSessionService } from "@internal/device-session/service/De import { DeviceSessionService } from "@internal/device-session/service/DeviceSessionService"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { UnknownDeviceError } from "@internal/usb/model/Errors"; import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; import { usbHidDeviceConnectionFactoryStubBuilder } from "@internal/usb/service/UsbHidDeviceConnectionFactory.stub"; @@ -14,9 +18,13 @@ import { WebUsbHidTransport } from "@internal/usb/transport/WebUsbHidTransport"; import { ConnectUseCase } from "./ConnectUseCase"; +jest.mock("@internal/manager-api/data/AxiosManagerApiDataSource"); + let transport: WebUsbHidTransport; let logger: LoggerPublisherService; let sessionService: DeviceSessionService; +let managerApi: ManagerApiService; +let managerApiDataSource: ManagerApiDataSource; const fakeSessionId = "fakeSessionId"; describe("ConnectUseCase", () => { @@ -32,6 +40,10 @@ describe("ConnectUseCase", () => { usbHidDeviceConnectionFactoryStubBuilder(), ); sessionService = new DefaultDeviceSessionService(() => logger); + managerApiDataSource = new AxiosManagerApiDataSource({ + managerApiUrl: "http://fake.url", + }); + managerApi = new DefaultManagerApiService(managerApiDataSource); }); afterAll(() => { @@ -43,7 +55,12 @@ describe("ConnectUseCase", () => { .spyOn(transport, "connect") .mockResolvedValue(Left(new UnknownDeviceError())); - const usecase = new ConnectUseCase(transport, sessionService, () => logger); + const usecase = new ConnectUseCase( + transport, + sessionService, + () => logger, + managerApi, + ); await expect(usecase.execute({ deviceId: "" })).rejects.toBeInstanceOf( UnknownDeviceError, @@ -55,7 +72,12 @@ describe("ConnectUseCase", () => { .spyOn(transport, "connect") .mockResolvedValue(Promise.resolve(Right(stubConnectedDevice))); - const usecase = new ConnectUseCase(transport, sessionService, () => logger); + const usecase = new ConnectUseCase( + transport, + sessionService, + () => logger, + managerApi, + ); const sessionId = await usecase.execute({ deviceId: "" }); expect(sessionId).toBe(fakeSessionId); diff --git a/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts b/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts index 853c60032..f460aac5d 100644 --- a/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts +++ b/packages/core/src/internal/discovery/use-case/ConnectUseCase.ts @@ -7,6 +7,8 @@ import { DeviceSession } from "@internal/device-session/model/DeviceSession"; import type { DeviceSessionService } from "@internal/device-session/service/DeviceSessionService"; import { loggerTypes } from "@internal/logger-publisher/di/loggerTypes"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { managerApiTypes } from "@internal/manager-api/di/managerApiTypes"; +import type { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { usbDiTypes } from "@internal/usb/di/usbDiTypes"; import type { UsbHidTransport } from "@internal/usb/transport/UsbHidTransport"; @@ -28,6 +30,7 @@ export class ConnectUseCase { private readonly _usbHidTransport: UsbHidTransport; private readonly _sessionService: DeviceSessionService; private readonly _loggerFactory: (tag: string) => LoggerPublisherService; + private readonly _managerApi: ManagerApiService; private readonly _logger: LoggerPublisherService; constructor( @@ -37,11 +40,14 @@ export class ConnectUseCase { sessionService: DeviceSessionService, @inject(loggerTypes.LoggerPublisherServiceFactory) loggerFactory: (tag: string) => LoggerPublisherService, + @inject(managerApiTypes.ManagerApiService) + managerApi: ManagerApiService, ) { this._sessionService = sessionService; this._usbHidTransport = usbHidTransport; this._loggerFactory = loggerFactory; this._logger = loggerFactory("ConnectUseCase"); + this._managerApi = managerApi; } private handleDeviceDisconnect(deviceId: DeviceId) { @@ -69,6 +75,7 @@ export class ConnectUseCase { const deviceSession = new DeviceSession( { connectedDevice }, this._loggerFactory, + this._managerApi, ); this._sessionService.addDeviceSession(deviceSession); return deviceSession.id; diff --git a/packages/core/src/internal/discovery/use-case/DisconnectUseCase.test.ts b/packages/core/src/internal/discovery/use-case/DisconnectUseCase.test.ts index 42e3aceda..49b18d3ce 100644 --- a/packages/core/src/internal/discovery/use-case/DisconnectUseCase.test.ts +++ b/packages/core/src/internal/discovery/use-case/DisconnectUseCase.test.ts @@ -5,6 +5,10 @@ import { deviceSessionStubBuilder } from "@internal/device-session/model/DeviceS import { DeviceSessionNotFound } from "@internal/device-session/model/Errors"; import { DefaultDeviceSessionService } from "@internal/device-session/service/DefaultDeviceSessionService"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { DisconnectError } from "@internal/usb/model/Errors"; import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; import { usbHidDeviceConnectionFactoryStubBuilder } from "@internal/usb/service/UsbHidDeviceConnectionFactory.stub"; @@ -20,6 +24,9 @@ const loggerFactory = jest new DefaultLoggerPublisherService([], "DisconnectUseCaseTest"), ); +let managerApi: ManagerApiService; +let managerApiDataSource: ManagerApiDataSource; + const sessionId = "sessionId"; describe("DisconnectUseCase", () => { @@ -35,12 +42,17 @@ describe("DisconnectUseCase", () => { it("should disconnect from a device", async () => { // Given const connectedDevice = connectedDeviceStubBuilder(); + managerApiDataSource = new AxiosManagerApiDataSource({ + managerApiUrl: "http://fake.url", + }); + managerApi = new DefaultManagerApiService(managerApiDataSource); const deviceSession = deviceSessionStubBuilder( { id: sessionId, connectedDevice, }, loggerFactory, + managerApi, ); jest .spyOn(sessionService, "getDeviceSessionById") @@ -87,7 +99,13 @@ describe("DisconnectUseCase", () => { jest .spyOn(sessionService, "getDeviceSessionById") .mockImplementation(() => - Right(deviceSessionStubBuilder({ id: sessionId }, loggerFactory)), + Right( + deviceSessionStubBuilder( + { id: sessionId }, + loggerFactory, + managerApi, + ), + ), ); jest .spyOn(usbHidTransport, "disconnect") diff --git a/packages/core/src/internal/manager-api/data/AxiosManagerApiDataSource.test.ts b/packages/core/src/internal/manager-api/data/AxiosManagerApiDataSource.test.ts new file mode 100644 index 000000000..f9368d439 --- /dev/null +++ b/packages/core/src/internal/manager-api/data/AxiosManagerApiDataSource.test.ts @@ -0,0 +1,92 @@ +import axios from "axios"; +import { Left, Right } from "purify-ts"; + +import { + BTC_APP, + BTC_APP_METADATA, + CUSTOM_LOCK_SCREEN_APP, + CUSTOM_LOCK_SCREEN_APP_METADATA, +} from "@api/device-action/__test-utils__/data"; +import { DEFAULT_MANAGER_API_BASE_URL } from "@internal/manager-api//model/Const"; +import { HttpFetchApiError } from "@internal/manager-api/model/Errors"; + +import { AxiosManagerApiDataSource } from "./AxiosManagerApiDataSource"; + +jest.mock("axios"); + +describe("AxiosManagerApiDataSource", () => { + describe("getAppsByHash", () => { + describe("success cases", () => { + it("with BTC app, should return the metadata", async () => { + const api = new AxiosManagerApiDataSource({ + managerApiUrl: DEFAULT_MANAGER_API_BASE_URL, + }); + + jest.spyOn(axios, "post").mockResolvedValue({ + data: [BTC_APP_METADATA], + }); + + const hashes = [BTC_APP.appFullHash]; + + const apps = await api.getAppsByHash(hashes); + + expect(apps).toEqual(Right([BTC_APP_METADATA])); + }); + + it("with no apps, should return an empty list", async () => { + const api = new AxiosManagerApiDataSource({ + managerApiUrl: DEFAULT_MANAGER_API_BASE_URL, + }); + + jest.spyOn(axios, "post").mockResolvedValue({ + data: [], + }); + + const hashes: string[] = []; + + const apps = await api.getAppsByHash(hashes); + + expect(apps).toEqual(Right([])); + }); + + it("with BTC app and custom lock screen, should return the metadata", async () => { + const api = new AxiosManagerApiDataSource({ + managerApiUrl: DEFAULT_MANAGER_API_BASE_URL, + }); + + jest.spyOn(axios, "post").mockResolvedValue({ + data: [BTC_APP_METADATA, CUSTOM_LOCK_SCREEN_APP_METADATA], + }); + + const hashes = [ + BTC_APP.appFullHash, + CUSTOM_LOCK_SCREEN_APP.appFullHash, + ]; + + const apps = await api.getAppsByHash(hashes); + + expect(apps).toEqual( + Right([BTC_APP_METADATA, CUSTOM_LOCK_SCREEN_APP_METADATA]), + ); + }); + }); + + describe("error cases", () => { + it("should throw an error if the request fails", async () => { + const api = new AxiosManagerApiDataSource({ + managerApiUrl: DEFAULT_MANAGER_API_BASE_URL, + }); + const err = new Error("fetch error"); + jest.spyOn(axios, "post").mockRejectedValue(err); + + const hashes = [BTC_APP.appFullHash]; + + try { + await api.getAppsByHash(hashes); + } catch (error) { + expect(error).toEqual(Left(new HttpFetchApiError(err))); + } + }); + }); + }); +}); diff --git a/packages/core/src/internal/manager-api/data/AxiosManagerApiDataSource.ts b/packages/core/src/internal/manager-api/data/AxiosManagerApiDataSource.ts new file mode 100644 index 000000000..c586991a3 --- /dev/null +++ b/packages/core/src/internal/manager-api/data/AxiosManagerApiDataSource.ts @@ -0,0 +1,66 @@ +import axios from "axios"; +import { inject, injectable } from "inversify"; +import { EitherAsync } from "purify-ts"; + +import { type SdkConfig } from "@api/SdkConfig"; +import { managerApiTypes } from "@internal/manager-api/di/managerApiTypes"; +import { HttpFetchApiError } from "@internal/manager-api/model/Errors"; +import { + Application, + AppType, +} from "@internal/manager-api/model/ManagerApiType"; + +import { ManagerApiDataSource } from "./ManagerApiDataSource"; +import { ApplicationDto, AppTypeDto } from "./ManagerApiDto"; + +@injectable() +export class AxiosManagerApiDataSource implements ManagerApiDataSource { + private readonly baseUrl: string; + constructor(@inject(managerApiTypes.SdkConfig) config: SdkConfig) { + this.baseUrl = config.managerApiUrl; + } + + private mapAppTypeDtoToAppType(appType: AppTypeDto): AppType { + switch (appType) { + case AppTypeDto.currency: + return AppType.currency; + case AppTypeDto.plugin: + return AppType.plugin; + case AppTypeDto.tool: + return AppType.tool; + case AppTypeDto.swap: + return AppType.swap; + } + } + + private mapApplicationDtoToApplication( + apps: Array, + ): Array { + return apps.map((app) => { + if (app === null) { + return null; + } + + const { applicationType, ...rest } = app; + + return { + ...rest, + applicationType: this.mapAppTypeDtoToAppType(applicationType), + }; + }); + } + + getAppsByHash( + hashes: string[], + ): EitherAsync> { + return EitherAsync(() => + axios.post>( + `${this.baseUrl}/v2/apps/hash`, + hashes, + ), + ) + .map((res) => res.data) + .map((apps) => this.mapApplicationDtoToApplication(apps)) + .mapLeft((error) => new HttpFetchApiError(error)); + } +} diff --git a/packages/core/src/internal/manager-api/data/ManagerApiDataSource.ts b/packages/core/src/internal/manager-api/data/ManagerApiDataSource.ts new file mode 100644 index 000000000..e5a90491e --- /dev/null +++ b/packages/core/src/internal/manager-api/data/ManagerApiDataSource.ts @@ -0,0 +1,10 @@ +import { EitherAsync } from "purify-ts"; + +import { HttpFetchApiError } from "@internal/manager-api/model/Errors"; +import { Application } from "@internal/manager-api/model/ManagerApiType"; + +export interface ManagerApiDataSource { + getAppsByHash( + hashes: string[], + ): EitherAsync>; +} diff --git a/packages/core/src/internal/manager-api/data/ManagerApiDto.ts b/packages/core/src/internal/manager-api/data/ManagerApiDto.ts new file mode 100644 index 000000000..7b1f06a55 --- /dev/null +++ b/packages/core/src/internal/manager-api/data/ManagerApiDto.ts @@ -0,0 +1,34 @@ +export type Id = number; + +export enum AppTypeDto { + currency = "currency", + plugin = "plugin", + tool = "tool", + swap = "swap", +} + +export type ApplicationDto = { + versionId: Id; + versionName: string; + versionDisplayName: string; + version: string; + currencyId: string; + description: string; + applicationType: AppTypeDto; + dateModified: string; + icon: string; + authorName: string; + supportURL: string; + contactURL: string; + sourceURL: string; + hash: string; + perso: string; + parentName: string | null; + firmware: string; + firmwareKey: string; + delete: string; + deleteKey: string; + bytes: number; + warning: string | null; + isDevTools: boolean; +}; diff --git a/packages/core/src/internal/manager-api/data/__mocks__/AxiosManagerApiDataSource.ts b/packages/core/src/internal/manager-api/data/__mocks__/AxiosManagerApiDataSource.ts new file mode 100644 index 000000000..1f3c0076a --- /dev/null +++ b/packages/core/src/internal/manager-api/data/__mocks__/AxiosManagerApiDataSource.ts @@ -0,0 +1,5 @@ +import { ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; + +export class AxiosManagerApiDataSource implements ManagerApiDataSource { + getAppsByHash = jest.fn(); +} diff --git a/packages/core/src/internal/manager-api/di/managerApiModule.test.ts b/packages/core/src/internal/manager-api/di/managerApiModule.test.ts new file mode 100644 index 000000000..db5e73882 --- /dev/null +++ b/packages/core/src/internal/manager-api/di/managerApiModule.test.ts @@ -0,0 +1,75 @@ +import { Container } from "inversify"; + +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import { StubUseCase } from "@root/src/di.stub"; + +import { managerApiModuleFactory } from "./managerApiModule"; +import { managerApiTypes } from "./managerApiTypes"; +// import { types } from "./managerApiTypes"; + +describe("managerApiModuleFactory", () => { + describe("Default", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = managerApiModuleFactory({ + stub: false, + config: { managerApiUrl: "http://fake.url" }, + }); + container = new Container(); + container.load(mod); + }); + + it("should return the config module", () => { + expect(mod).toBeDefined(); + }); + + it("should return none stubbed use cases", () => { + const managerApiDataSource = container.get( + managerApiTypes.ManagerApiDataSource, + ); + expect(managerApiDataSource).toBeInstanceOf(AxiosManagerApiDataSource); + + const managerApiService = container.get( + managerApiTypes.ManagerApiService, + ); + expect(managerApiService).toBeInstanceOf(DefaultManagerApiService); + + const config = container.get(managerApiTypes.SdkConfig); + expect(config).toEqual({ managerApiUrl: "http://fake.url" }); + }); + }); + + describe("Stubbed", () => { + let container: Container; + let mod: ReturnType; + beforeEach(() => { + mod = managerApiModuleFactory({ + stub: true, + config: { managerApiUrl: "http://fake.url" }, + }); + container = new Container(); + container.load(mod); + }); + + it("should return the config module", () => { + expect(mod).toBeDefined(); + }); + + it("should return stubbed use cases", () => { + const managerApiDataSource = container.get( + managerApiTypes.ManagerApiDataSource, + ); + expect(managerApiDataSource).toBeInstanceOf(StubUseCase); + + const managerApiService = container.get( + managerApiTypes.ManagerApiService, + ); + expect(managerApiService).toBeInstanceOf(StubUseCase); + + const config = container.get(managerApiTypes.SdkConfig); + expect(config).toEqual({ managerApiUrl: "http://fake.url" }); + }); + }); +}); diff --git a/packages/core/src/internal/manager-api/di/managerApiModule.ts b/packages/core/src/internal/manager-api/di/managerApiModule.ts new file mode 100644 index 000000000..f0fed0209 --- /dev/null +++ b/packages/core/src/internal/manager-api/di/managerApiModule.ts @@ -0,0 +1,26 @@ +import { ContainerModule } from "inversify"; + +import { SdkConfig } from "@api/SdkConfig"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import { StubUseCase } from "@root/src/di.stub"; + +import { managerApiTypes } from "./managerApiTypes"; + +type FactoryProps = { + stub?: boolean; + config: SdkConfig; +}; + +export const managerApiModuleFactory = ({ stub, config }: FactoryProps) => + new ContainerModule((bind, _unbind, _isBound, rebind) => { + bind(managerApiTypes.SdkConfig).toConstantValue(config); + + bind(managerApiTypes.ManagerApiDataSource).to(AxiosManagerApiDataSource); + bind(managerApiTypes.ManagerApiService).to(DefaultManagerApiService); + + if (stub) { + rebind(managerApiTypes.ManagerApiDataSource).to(StubUseCase); + rebind(managerApiTypes.ManagerApiService).to(StubUseCase); + } + }); diff --git a/packages/core/src/internal/manager-api/di/managerApiTypes.ts b/packages/core/src/internal/manager-api/di/managerApiTypes.ts new file mode 100644 index 000000000..c2a68cc2c --- /dev/null +++ b/packages/core/src/internal/manager-api/di/managerApiTypes.ts @@ -0,0 +1,5 @@ +export const managerApiTypes = { + ManagerApiService: Symbol.for("ManagerApiService"), + ManagerApiDataSource: Symbol.for("ManagerApiDataSource"), + SdkConfig: Symbol.for("SdkConfig"), +}; diff --git a/packages/core/src/internal/manager-api/model/Const.ts b/packages/core/src/internal/manager-api/model/Const.ts new file mode 100644 index 000000000..6097ce49a --- /dev/null +++ b/packages/core/src/internal/manager-api/model/Const.ts @@ -0,0 +1,2 @@ +export const DEFAULT_MANAGER_API_BASE_URL = + "https://manager.api.live.ledger.com/api"; diff --git a/packages/core/src/internal/manager-api/model/Errors.ts b/packages/core/src/internal/manager-api/model/Errors.ts new file mode 100644 index 000000000..7d8f117be --- /dev/null +++ b/packages/core/src/internal/manager-api/model/Errors.ts @@ -0,0 +1,10 @@ +import { SdkError } from "@api/Error"; + +export class HttpFetchApiError implements SdkError { + _tag = "FetchError"; + originalError?: unknown; + + constructor(public readonly error: unknown) { + this.originalError = error; + } +} diff --git a/packages/core/src/internal/manager-api/model/ManagerApiType.ts b/packages/core/src/internal/manager-api/model/ManagerApiType.ts new file mode 100644 index 000000000..a7bec5818 --- /dev/null +++ b/packages/core/src/internal/manager-api/model/ManagerApiType.ts @@ -0,0 +1,34 @@ +export type Id = number; + +export enum AppType { + currency = "currency", + plugin = "plugin", + tool = "tool", + swap = "swap", +} + +export type Application = { + versionId: Id; + versionName: string; + versionDisplayName: string; + version: string; + currencyId: string; + description: string; + applicationType: AppType; + dateModified: string; + icon: string; + authorName: string; + supportURL: string; + contactURL: string; + sourceURL: string; + hash: string; + perso: string; + parentName: string | null; + firmware: string; + firmwareKey: string; + delete: string; + deleteKey: string; + bytes: number; + warning: string | null; + isDevTools: boolean; +}; diff --git a/packages/core/src/internal/manager-api/service/DefaultManagerApiService.test.ts b/packages/core/src/internal/manager-api/service/DefaultManagerApiService.test.ts new file mode 100644 index 000000000..d2c2f45c5 --- /dev/null +++ b/packages/core/src/internal/manager-api/service/DefaultManagerApiService.test.ts @@ -0,0 +1,75 @@ +import { Left, Right } from "purify-ts"; + +import { + BTC_APP, + BTC_APP_METADATA, + ETH_APP, + ETH_APP_METADATA, +} from "@api/device-action/__test-utils__/data"; +import { DEFAULT_MANAGER_API_BASE_URL } from "@internal/manager-api//model/Const"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { HttpFetchApiError } from "@internal/manager-api/model/Errors"; + +import { DefaultManagerApiService } from "./DefaultManagerApiService"; +import { ManagerApiService } from "./ManagerApiService"; + +jest.mock("@internal/manager-api/data/AxiosManagerApiDataSource"); +let dataSource: jest.Mocked; +let service: ManagerApiService; +describe("ManagerApiService", () => { + beforeEach(() => { + dataSource = new AxiosManagerApiDataSource({ + managerApiUrl: DEFAULT_MANAGER_API_BASE_URL, + }) as jest.Mocked; + service = new DefaultManagerApiService(dataSource); + }); + + describe("getAppsByHash", () => { + describe("success cases", () => { + it("with no apps, should return an empty list", async () => { + dataSource.getAppsByHash.mockResolvedValue(Right([])); + expect(await service.getAppsByHash([])).toEqual(Right([])); + }); + + it("with one app, should return the metadata", async () => { + dataSource.getAppsByHash.mockResolvedValue(Right([BTC_APP_METADATA])); + expect(await service.getAppsByHash([BTC_APP])).toEqual( + Right([BTC_APP_METADATA]), + ); + }); + + it("with two app, should return the metadata of both apps", async () => { + dataSource.getAppsByHash.mockResolvedValue( + Right([BTC_APP_METADATA, ETH_APP_METADATA]), + ); + expect(await service.getAppsByHash([BTC_APP, ETH_APP])).toEqual( + Right([BTC_APP_METADATA, ETH_APP_METADATA]), + ); + }); + + it("with one app and one without `appFullHash`, should return the metadata of the correct app", async () => { + dataSource.getAppsByHash.mockResolvedValue(Right([BTC_APP_METADATA])); + const APP_WITH_NO_HASH = { ...ETH_APP, appFullHash: "" }; + expect( + await service.getAppsByHash([BTC_APP, APP_WITH_NO_HASH]), + ).toEqual(Right([BTC_APP_METADATA])); + }); + }); + + describe("error cases", () => { + it("should return an error when the data source fails with a known error", async () => { + const error = new HttpFetchApiError(new Error("Failed to fetch data")); + dataSource.getAppsByHash.mockRejectedValue(error); + expect(await service.getAppsByHash([BTC_APP])).toEqual(Left(error)); + }); + + it("should return an error when the data source fails with an unknown error", async () => { + const error = new Error("unkown error"); + dataSource.getAppsByHash.mockRejectedValue(error); + expect(await service.getAppsByHash([BTC_APP])).toEqual( + Left(new HttpFetchApiError(error)), + ); + }); + }); + }); +}); diff --git a/packages/core/src/internal/manager-api/service/DefaultManagerApiService.ts b/packages/core/src/internal/manager-api/service/DefaultManagerApiService.ts new file mode 100644 index 000000000..1de3a3f83 --- /dev/null +++ b/packages/core/src/internal/manager-api/service/DefaultManagerApiService.ts @@ -0,0 +1,50 @@ +import { inject, injectable } from "inversify"; +import { EitherAsync } from "purify-ts"; + +import { ListAppsResponse } from "@api/command/os/ListAppsCommand"; +import { type ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; +import { managerApiTypes } from "@internal/manager-api/di/managerApiTypes"; +import { HttpFetchApiError } from "@internal/manager-api/model/Errors"; +import { Application } from "@internal/manager-api/model/ManagerApiType"; + +import { ManagerApiService } from "./ManagerApiService"; + +@injectable() +export class DefaultManagerApiService implements ManagerApiService { + constructor( + @inject(managerApiTypes.ManagerApiDataSource) + private readonly dataSource: ManagerApiDataSource, + ) { + this.dataSource = dataSource; + } + + getAppsByHash(apps: ListAppsResponse) { + const hashes = apps.reduce((acc, app) => { + if (app.appFullHash) { + return acc.concat(app.appFullHash); + } + + return acc; + }, []); + + return EitherAsync>( + async ({ fromPromise, throwE }) => { + if (hashes.length === 0) { + return []; + } + try { + const response = await fromPromise( + this.dataSource.getAppsByHash(hashes), + ); + return response; + } catch (error) { + if (error instanceof HttpFetchApiError) { + return throwE(error); + } + + return throwE(new HttpFetchApiError(error)); + } + }, + ); + } +} diff --git a/packages/core/src/internal/manager-api/service/ManagerApiService.ts b/packages/core/src/internal/manager-api/service/ManagerApiService.ts new file mode 100644 index 000000000..466702f05 --- /dev/null +++ b/packages/core/src/internal/manager-api/service/ManagerApiService.ts @@ -0,0 +1,11 @@ +import { EitherAsync } from "purify-ts"; + +import { ListAppsResponse } from "@api/command/os/ListAppsCommand"; +import { HttpFetchApiError } from "@internal/manager-api/model/Errors"; +import { Application } from "@internal/manager-api/model/ManagerApiType"; + +export interface ManagerApiService { + getAppsByHash( + apps: ListAppsResponse, + ): EitherAsync>; +} diff --git a/packages/core/src/internal/send/di/sendModule.test.ts b/packages/core/src/internal/send/di/sendModule.test.ts index 708a9ec59..f176a3b21 100644 --- a/packages/core/src/internal/send/di/sendModule.test.ts +++ b/packages/core/src/internal/send/di/sendModule.test.ts @@ -7,7 +7,7 @@ describe("sendModuleFactory", () => { let container: Container; let mod: ReturnType; beforeEach(() => { - mod = sendModuleFactory(); + mod = sendModuleFactory({ stub: false }); container = new Container(); container.load(mod); }); diff --git a/packages/core/src/internal/send/di/sendModule.ts b/packages/core/src/internal/send/di/sendModule.ts index 8bc2c7e1c..1bd7f367a 100644 --- a/packages/core/src/internal/send/di/sendModule.ts +++ b/packages/core/src/internal/send/di/sendModule.ts @@ -9,9 +9,7 @@ type FactoryProps = { stub: boolean; }; -export const sendModuleFactory = ({ - stub = false, -}: Partial = {}) => +export const sendModuleFactory = ({ stub = false }: FactoryProps) => new ContainerModule( ( bind, diff --git a/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts b/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts index b819fb50e..2c8284ed7 100644 --- a/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts +++ b/packages/core/src/internal/send/use-case/SendApduUseCase.test.ts @@ -9,22 +9,38 @@ import { DefaultDeviceSessionService } from "@internal/device-session/service/De import { DeviceSessionService } from "@internal/device-session/service/DeviceSessionService"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { SendApduUseCase } from "@internal/send/use-case/SendApduUseCase"; import { connectedDeviceStubBuilder } from "@internal/usb/model/InternalConnectedDevice.stub"; +jest.mock("@internal/manager-api/data/AxiosManagerApiDataSource"); + let logger: LoggerPublisherService; let sessionService: DeviceSessionService; +let managerApiDataSource: ManagerApiDataSource; +let managerApi: ManagerApiService; const fakeSessionId = "fakeSessionId"; describe("SendApduUseCase", () => { beforeEach(() => { logger = new DefaultLoggerPublisherService([], "send-apdu-use-case"); sessionService = new DefaultDeviceSessionService(() => logger); + managerApiDataSource = new AxiosManagerApiDataSource({ + managerApiUrl: "http://fake.url", + }); + managerApi = new DefaultManagerApiService(managerApiDataSource); }); it("should send an APDU to a connected device", async () => { // given - const deviceSession = deviceSessionStubBuilder({}, () => logger); + const deviceSession = deviceSessionStubBuilder( + {}, + () => logger, + managerApi, + ); sessionService.addDeviceSession(deviceSession); const useCase = new SendApduUseCase(sessionService, () => logger); @@ -63,6 +79,7 @@ describe("SendApduUseCase", () => { const deviceSession = deviceSessionStubBuilder( { connectedDevice }, () => logger, + managerApi, ); sessionService.addDeviceSession(deviceSession); const useCase = new SendApduUseCase(sessionService, () => logger); diff --git a/packages/core/src/internal/usb/di/usbModule.test.ts b/packages/core/src/internal/usb/di/usbModule.test.ts index bfb864a70..3764205a1 100644 --- a/packages/core/src/internal/usb/di/usbModule.test.ts +++ b/packages/core/src/internal/usb/di/usbModule.test.ts @@ -12,12 +12,12 @@ describe("usbModuleFactory", () => { let container: Container; let mod: ReturnType; beforeEach(() => { - mod = usbModuleFactory(); + mod = usbModuleFactory({ stub: false }); container = new Container(); container.load(loggerModuleFactory()); container.load( mod, - deviceModelModuleFactory(), + deviceModelModuleFactory({ stub: false }), deviceSessionModuleFactory(), ); }); diff --git a/packages/core/src/internal/usb/di/usbModule.ts b/packages/core/src/internal/usb/di/usbModule.ts index 407344293..bc3267faa 100644 --- a/packages/core/src/internal/usb/di/usbModule.ts +++ b/packages/core/src/internal/usb/di/usbModule.ts @@ -11,9 +11,7 @@ type FactoryProps = { stub: boolean; }; -export const usbModuleFactory = ({ - stub = false, -}: Partial = {}) => +export const usbModuleFactory = ({ stub = false }: FactoryProps) => new ContainerModule((bind, _unbind, _isBound, rebind) => { // The transport needs to be a singleton to keep the internal states of the devices bind(usbDiTypes.UsbHidTransport).to(WebUsbHidTransport).inSingletonScope(); diff --git a/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts b/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts index 939d3bf85..bf9fac291 100644 --- a/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts +++ b/packages/core/src/internal/usb/use-case/GetConnectedDeviceUseCase.test.ts @@ -3,11 +3,19 @@ import { DefaultDeviceSessionService } from "@internal/device-session/service/De import { DeviceSessionService } from "@internal/device-session/service/DeviceSessionService"; import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService"; import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService"; +import { AxiosManagerApiDataSource } from "@internal/manager-api/data/AxiosManagerApiDataSource"; +import { ManagerApiDataSource } from "@internal/manager-api/data/ManagerApiDataSource"; +import { DefaultManagerApiService } from "@internal/manager-api/service/DefaultManagerApiService"; +import { ManagerApiService } from "@internal/manager-api/service/ManagerApiService"; import { GetConnectedDeviceUseCase } from "@internal/usb/use-case/GetConnectedDeviceUseCase"; import { ConnectedDevice } from "@root/src"; +jest.mock("@internal/manager-api/data/AxiosManagerApiDataSource"); + let logger: LoggerPublisherService; let sessionService: DeviceSessionService; +let managerApiDataSource: ManagerApiDataSource; +let managerApi: ManagerApiService; const fakeSessionId = "fakeSessionId"; @@ -17,6 +25,10 @@ describe("GetConnectedDevice", () => { [], "get-connected-device-use-case", ); + managerApiDataSource = new AxiosManagerApiDataSource({ + managerApiUrl: "http://fake.url", + }); + managerApi = new DefaultManagerApiService(managerApiDataSource); sessionService = new DefaultDeviceSessionService(() => logger); }); @@ -25,6 +37,7 @@ describe("GetConnectedDevice", () => { const deviceSession = deviceSessionStubBuilder( { id: fakeSessionId }, () => logger, + managerApi, ); sessionService.addDeviceSession(deviceSession); const useCase = new GetConnectedDeviceUseCase(sessionService, () => logger); @@ -43,6 +56,7 @@ describe("GetConnectedDevice", () => { const deviceSession = deviceSessionStubBuilder( { id: fakeSessionId }, () => logger, + managerApi, ); sessionService.addDeviceSession(deviceSession); const useCase = new GetConnectedDeviceUseCase(sessionService, () => logger); diff --git a/packages/core/tsconfig.cjs.json b/packages/core/tsconfig.cjs.json index 2a786393f..63c80c465 100644 --- a/packages/core/tsconfig.cjs.json +++ b/packages/core/tsconfig.cjs.json @@ -8,8 +8,8 @@ "jest.*.ts" ], "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", + "module": "nodenext", + "moduleResolution": "nodenext", "outDir": "./lib/cjs", "declarationDir": "./lib/cjs" } diff --git a/packages/signer/context-module/README.md b/packages/signer/context-module/README.md index f8b403ce9..d9d4f081b 100644 --- a/packages/signer/context-module/README.md +++ b/packages/signer/context-module/README.md @@ -1 +1,118 @@ -# Context Module +# Ledger Context Module Implementation + +> [!CAUTION] +> This is still under development and we are free to make new interfaces which may lead to Device SDK breaking changes. + +## Introduction + +The purpose of the **Context Module** is to provide all the necessary context for the clear signing operation. +This module includes the Ledger implementation of the context module and all the default context loaders used to fetch the context of a transaction. +This open-source module can serve as an example for implementing custom context modules or loaders. + +## How does it work + +The Context Module features an interface utilized by the Signer module to retrieve the context of a transaction. This module comprises multiple loaders, each capable of being specified individually. Each loader attempts to fetch context from the backend relevant to its domain. For example, one loader retrieves information about tokens, another fetches information about NFTs, and so on. + +The following diagram illustrates the communication between the various modules when the context for a token transaction is successfully retrieved: + +```mermaid + flowchart LR; + Signer --Transaction--> ContextModule + ContextModule --Transaction--> TokenContextLoader + ContextModule --Transaction--> NftContextLoader + ContextModule --Transaction--> OtherContextLoader + TokenContextLoader --> Backend1(Backend) + NftContextLoader --> Backend2(Backend) + OtherContextLoader --> Backend3(Backend) + Backend1 --Context--> TokenContextLoader + TokenContextLoader --Context--> ContextModule + ContextModule --Context--> Signer +``` + +## Installation + +To install the context-module package, run the following command: + +```sh +npm install @ledgerhq/context-module +``` + +## Usage + +### Main Features + +It currently supports the following features: + +- Tokens: provide information about tokens used in the transaction. +- NFTs: provide information about NFTs used in the transaction. +- Domain name: provide information about domain names. +- Custom plugins: provide complex informations to external plugins such as the **1inch** or **paraswap** plugin. + +> [!NOTE] +> At the moment the context module is available only for Ethereum blockchain. + +### Setting up + +The context-module package exposes a builder `ContextModuleBuilder` which will be used to initialise the context module with your configuration. + +```ts +const contextModule = new ContextModuleBuilder().build(); +``` + +It is also possible to instantiate the context module without the default loaders. + +```ts +const contextModule = new ContextModuleBuilder() + .withoutDefaultLoaders() + .build(); +``` + +> [!NOTE] +> Without loaders, a transaction cannot be clear signed. Use it with caution. + +You can add a custom list of loader to the context module. + +```ts +// Default Token Loader +const tokenLoader = new TokenContextLoader(new TokenDataSource()); + +// Custom Loader +const myCustomLoader = new MyCustomLoader(); + +// Custom datasource for a default Token Loader +const myCustomTokenDataSource = new MyCustomTokenDataSource(); +const myTokenLoader = new TokenCOntextLoader(); + +const contextModule = new ContextModuleBuilder() + .withoutDefaultLoaders() + .addLoader(tokenLoader) + .addLoader(myTokenLoader) + .addLoader(myCustomLoader) + .build(); +``` + +### Create a custom loader + +A custom loader must implement the ContextLoader interface, defined as follows: + +```ts +type ContextLoader = { + load: (transaction: TransactionContext) => Promise; +}; +``` + +with ClearSignContextSuccess defined as follows: + +```ts +type ClearSignContextSuccess = { + type: "token" | "nft" | "domainName" | "plugin" | "externalPlugin"; + payload: string; +}; +``` + +The payload should represent the data sent to the device to provide information and must be signed by a trusted authority. + +### Errors handling + +> [!CAUTION] +> To be defined diff --git a/packages/signer/context-module/index.ts b/packages/signer/context-module/index.ts index e910bb060..f843c430e 100644 --- a/packages/signer/context-module/index.ts +++ b/packages/signer/context-module/index.ts @@ -1 +1,4 @@ +// inversify requirement +import "reflect-metadata"; + export * from "./src/index"; diff --git a/packages/signer/context-module/jest.config.ts b/packages/signer/context-module/jest.config.ts index 89d9a684a..175e4572f 100644 --- a/packages/signer/context-module/jest.config.ts +++ b/packages/signer/context-module/jest.config.ts @@ -1,5 +1,11 @@ /* eslint no-restricted-syntax: 0 */ -import type { JestConfigWithTsJest } from "ts-jest"; +import { JestConfigWithTsJest, pathsToModuleNameMapper } from "ts-jest"; + +import { compilerOptions } from "./tsconfig.json"; + +const paths = pathsToModuleNameMapper(compilerOptions.paths, { + prefix: "/", +}); const config: JestConfigWithTsJest = { preset: "@ledgerhq/jest-config-dsdk", @@ -12,8 +18,7 @@ const config: JestConfigWithTsJest = { "!src/api/index.ts", ], moduleNameMapper: { - "^@/(.*)$": "/src/$1", - "^@root/(.*)$": "/$1", + ...paths, }, }; diff --git a/packages/signer/context-module/package.json b/packages/signer/context-module/package.json index e007ce879..a3bc6b792 100644 --- a/packages/signer/context-module/package.json +++ b/packages/signer/context-module/package.json @@ -41,17 +41,23 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@ledgerhq/device-sdk-core": "workspace:*", "@ledgerhq/eslint-config-dsdk": "workspace:*", "@ledgerhq/jest-config-dsdk": "workspace:*", "@ledgerhq/prettier-config-dsdk": "workspace:*", "@ledgerhq/tsconfig-dsdk": "workspace:*", + "@types/crypto-js": "^4.2.2", "ts-node": "^10.9.2" }, "dependencies": { "axios": "^1.7.2", - "ethers": "^5.7.2", + "crypto-js": "^4.2.0", + "ethers": "^6.13.2", "inversify": "^6.0.2", "purify-ts": "^2.1.0", "reflect-metadata": "^0.2.2" + }, + "peerDependencies": { + "@ledgerhq/device-sdk-core": "workspace:*" } } diff --git a/packages/signer/context-module/scripts/build.mjs b/packages/signer/context-module/scripts/build.mjs index ac064aee1..d80548df4 100644 --- a/packages/signer/context-module/scripts/build.mjs +++ b/packages/signer/context-module/scripts/build.mjs @@ -19,4 +19,5 @@ const run = async () => await Promise.all([buildEsm(), builCjs()]); run().catch((e) => { console.error(e); + process.exitCode = e.exitCode; }); diff --git a/packages/signer/context-module/src/ContextModule.ts b/packages/signer/context-module/src/ContextModule.ts index 92efdbc27..3123e1c15 100644 --- a/packages/signer/context-module/src/ContextModule.ts +++ b/packages/signer/context-module/src/ContextModule.ts @@ -1,7 +1,12 @@ import { ClearSignContext } from "@/shared/model/ClearSignContext"; import { TransactionContext } from "./shared/model/TransactionContext"; +import { type TypedDataClearSignContext } from "./shared/model/TypedDataClearSignContext"; +import { type TypedDataContext } from "./shared/model/TypedDataContext"; export interface ContextModule { getContexts(transaction: TransactionContext): Promise; + getTypedDataFilters( + typedData: TypedDataContext, + ): Promise; } diff --git a/packages/signer/context-module/src/ContextModuleBuilder.ts b/packages/signer/context-module/src/ContextModuleBuilder.ts index 91ea631ca..e3fa42af3 100644 --- a/packages/signer/context-module/src/ContextModuleBuilder.ts +++ b/packages/signer/context-module/src/ContextModuleBuilder.ts @@ -2,12 +2,14 @@ import { externalPluginTypes } from "@/external-plugin/di/externalPluginTypes"; import { forwardDomainTypes } from "@/forward-domain/di/forwardDomainTypes"; import { nftTypes } from "@/nft/di/nftTypes"; import { tokenTypes } from "@/token/di/tokenTypes"; +import { typedDataTypes } from "@/typed-data/di/typedDataTypes"; import { ExternalPluginContextLoader } from "./external-plugin/domain/ExternalPluginContextLoader"; import { ForwardDomainContextLoader } from "./forward-domain/domain/ForwardDomainContextLoader"; import { NftContextLoader } from "./nft/domain/NftContextLoader"; import { ContextLoader } from "./shared/domain/ContextLoader"; import { TokenContextLoader } from "./token/domain/TokenContextLoader"; +import { type TypedDataContextLoader } from "./typed-data/domain/TypedDataContextLoader"; import { ContextModule } from "./ContextModule"; import { DefaultContextModule } from "./DefaultContextModule"; import { makeContainer } from "./di"; @@ -15,6 +17,7 @@ import { makeContainer } from "./di"; export class ContextModuleBuilder { private customLoaders: ContextLoader[] = []; private defaultLoaders: ContextLoader[] = []; + private typedDataLoader: TypedDataContextLoader; constructor() { const container = makeContainer(); @@ -29,6 +32,9 @@ export class ContextModuleBuilder { container.get(nftTypes.NftContextLoader), container.get(tokenTypes.TokenContextLoader), ]; + this.typedDataLoader = container.get( + typedDataTypes.TypedDataContextLoader, + ); } /** @@ -52,6 +58,16 @@ export class ContextModuleBuilder { return this; } + /** + * Replace the default loader for typed data clear signing contexts + * + * @returns this + */ + withTypedDataLoader(loader: TypedDataContextLoader) { + this.typedDataLoader = loader; + return this; + } + /** * Build the context module * @@ -59,6 +75,9 @@ export class ContextModuleBuilder { */ build(): ContextModule { const loaders = [...this.defaultLoaders, ...this.customLoaders]; - return new DefaultContextModule({ loaders }); + return new DefaultContextModule({ + loaders, + typedDataLoader: this.typedDataLoader, + }); } } diff --git a/packages/signer/context-module/src/DefaultContextModule.test.ts b/packages/signer/context-module/src/DefaultContextModule.test.ts index dccc046f6..fac586af9 100644 --- a/packages/signer/context-module/src/DefaultContextModule.test.ts +++ b/packages/signer/context-module/src/DefaultContextModule.test.ts @@ -1,4 +1,5 @@ import { TransactionContext } from "./shared/model/TransactionContext"; +import type { TypedDataContextLoader } from "./typed-data/domain/TypedDataContextLoader"; import { DefaultContextModule } from "./DefaultContextModule"; const contextLoaderStubBuilder = () => { @@ -6,12 +7,17 @@ const contextLoaderStubBuilder = () => { }; describe("DefaultContextModule", () => { + const typedDataLoader: TypedDataContextLoader = { load: jest.fn() }; + beforeEach(() => { jest.restoreAllMocks(); }); it("should initialize the context module with all the default loaders", async () => { - const contextModule = new DefaultContextModule({ loaders: [] }); + const contextModule = new DefaultContextModule({ + loaders: [], + typedDataLoader, + }); const res = await contextModule.getContexts({} as TransactionContext); @@ -19,7 +25,10 @@ describe("DefaultContextModule", () => { }); it("should return an empty array when no loaders", async () => { - const contextModule = new DefaultContextModule({ loaders: [] }); + const contextModule = new DefaultContextModule({ + loaders: [], + typedDataLoader, + }); const res = await contextModule.getContexts({} as TransactionContext); @@ -30,6 +39,7 @@ describe("DefaultContextModule", () => { const loader = contextLoaderStubBuilder(); const contextModule = new DefaultContextModule({ loaders: [loader, loader], + typedDataLoader, }); await contextModule.getContexts({} as TransactionContext); @@ -52,6 +62,7 @@ describe("DefaultContextModule", () => { .mockResolvedValueOnce(responses[1]); const contextModule = new DefaultContextModule({ loaders: [loader, loader], + typedDataLoader, }); const res = await contextModule.getContexts({} as TransactionContext); diff --git a/packages/signer/context-module/src/DefaultContextModule.ts b/packages/signer/context-module/src/DefaultContextModule.ts index 3e3c83957..c76243216 100644 --- a/packages/signer/context-module/src/DefaultContextModule.ts +++ b/packages/signer/context-module/src/DefaultContextModule.ts @@ -1,17 +1,24 @@ +import type { TypedDataClearSignContext } from "@/shared/model/TypedDataClearSignContext"; +import type { TypedDataContext } from "@/shared/model/TypedDataContext"; + import { ContextLoader } from "./shared/domain/ContextLoader"; import { ClearSignContext } from "./shared/model/ClearSignContext"; import { TransactionContext } from "./shared/model/TransactionContext"; +import type { TypedDataContextLoader } from "./typed-data/domain/TypedDataContextLoader"; import { ContextModule } from "./ContextModule"; type DefaultContextModuleConstructorArgs = { loaders: ContextLoader[]; + typedDataLoader: TypedDataContextLoader; }; export class DefaultContextModule implements ContextModule { private _loaders: ContextLoader[]; + private _typedDataLoader: TypedDataContextLoader; constructor(args: DefaultContextModuleConstructorArgs) { this._loaders = args.loaders; + this._typedDataLoader = args.typedDataLoader; } public async getContexts( @@ -21,4 +28,10 @@ export class DefaultContextModule implements ContextModule { const responses = await Promise.all(promises); return responses.flat(); } + + public async getTypedDataFilters( + typedData: TypedDataContext, + ): Promise { + return this._typedDataLoader.load(typedData); + } } diff --git a/packages/signer/context-module/src/di.ts b/packages/signer/context-module/src/di.ts index 1ba7ffbeb..0edca8670 100644 --- a/packages/signer/context-module/src/di.ts +++ b/packages/signer/context-module/src/di.ts @@ -4,6 +4,7 @@ import { externalPluginModuleFactory } from "@/external-plugin/di/externalPlugin import { forwardDomainModuleFactory } from "@/forward-domain/di/forwardDomainModuleFactory"; import { nftModuleFactory } from "@/nft/di/nftModuleFactory"; import { tokenModuleFactory } from "@/token/di/tokenModuleFactory"; +import { typedDataModuleFactory } from "@/typed-data/di/typedDataModuleFactory"; export const makeContainer = () => { const container = new Container(); @@ -13,6 +14,7 @@ export const makeContainer = () => { forwardDomainModuleFactory(), nftModuleFactory(), tokenModuleFactory(), + typedDataModuleFactory(), ); return container; diff --git a/packages/signer/context-module/src/external-plugin/data/ExternalPluginDataSource.ts b/packages/signer/context-module/src/external-plugin/data/ExternalPluginDataSource.ts index 694df54c6..328c66fac 100644 --- a/packages/signer/context-module/src/external-plugin/data/ExternalPluginDataSource.ts +++ b/packages/signer/context-module/src/external-plugin/data/ExternalPluginDataSource.ts @@ -1,11 +1,11 @@ +import { HexaString } from "@ledgerhq/device-sdk-core"; import { Either } from "purify-ts"; import { DappInfos } from "@/external-plugin/model/DappInfos"; -import { HexString } from "@/shared/model/HexString"; export type GetDappInfos = { address: string; - selector: HexString; + selector: HexaString; chainId: number; }; diff --git a/packages/signer/context-module/src/external-plugin/domain/ExternalPluginContextLoader.test.ts b/packages/signer/context-module/src/external-plugin/domain/ExternalPluginContextLoader.test.ts index 22d9a9a73..88c3c5c04 100644 --- a/packages/signer/context-module/src/external-plugin/domain/ExternalPluginContextLoader.test.ts +++ b/packages/signer/context-module/src/external-plugin/domain/ExternalPluginContextLoader.test.ts @@ -1,4 +1,4 @@ -import { Interface } from "ethers/lib/utils"; +import { Interface } from "ethers"; import { Left, Right } from "purify-ts"; import ABI from "@/external-plugin/__tests__/abi.json"; @@ -430,9 +430,7 @@ describe("ExternalPluginContextLoader", () => { expect(result).toEqual([ { type: "error", - error: new Error( - "[ContextModule] ExternalPluginContextLoader: Unable to get address", - ), + error: new RangeError("out of result range"), }, ]); }); diff --git a/packages/signer/context-module/src/external-plugin/domain/ExternalPluginContextLoader.ts b/packages/signer/context-module/src/external-plugin/domain/ExternalPluginContextLoader.ts index 7e769c8b8..d8f98d26f 100644 --- a/packages/signer/context-module/src/external-plugin/domain/ExternalPluginContextLoader.ts +++ b/packages/signer/context-module/src/external-plugin/domain/ExternalPluginContextLoader.ts @@ -1,5 +1,5 @@ -import { ethers } from "ethers"; -import { Interface } from "ethers/lib/utils"; +import { HexaString, isHexaString } from "@ledgerhq/device-sdk-core"; +import { ethers, Interface } from "ethers"; import { inject, injectable } from "inversify"; import { Either, EitherAsync, Left, Right } from "purify-ts"; @@ -7,7 +7,6 @@ import type { ExternalPluginDataSource } from "@/external-plugin/data/ExternalPl import { externalPluginTypes } from "@/external-plugin/di/externalPluginTypes"; import { ContextLoader } from "@/shared/domain/ContextLoader"; import { ClearSignContext } from "@/shared/model/ClearSignContext"; -import { HexString, isHexString } from "@/shared/model/HexString"; import { TransactionContext } from "@/shared/model/TransactionContext"; import type { TokenDataSource } from "@/token/data/TokenDataSource"; import { tokenTypes } from "@/token/di/tokenTypes"; @@ -33,7 +32,7 @@ export class ExternalPluginContextLoader implements ContextLoader { const selector = transaction.data.slice(0, 10); - if (!isHexString(selector)) { + if (!isHexaString(selector)) { return [{ type: "error" as const, error: new Error("Invalid selector") }]; } @@ -76,7 +75,7 @@ export class ExternalPluginContextLoader implements ContextLoader { // decodedCallData is a Right so we can extract it safely const extractedDecodedCallData = - decodedCallData.extract() as ethers.utils.Result; + decodedCallData.extract() as ethers.Result; // get the token payload for each erc20OfInterest // and return the payload or the error @@ -110,7 +109,7 @@ export class ExternalPluginContextLoader implements ContextLoader { private getTokenPayload( transaction: TransactionContext, erc20Path: string, - decodedCallData: ethers.utils.Result, + decodedCallData: ethers.Result, ) { const address = this.getAddressFromPath(erc20Path, decodedCallData); @@ -128,7 +127,7 @@ export class ExternalPluginContextLoader implements ContextLoader { abi: object[], method: string, data: string, - ): Either { + ): Either { try { const contractInterface = new Interface(abi); return Right(contractInterface.decodeFunctionData(method, data)); @@ -143,9 +142,9 @@ export class ExternalPluginContextLoader implements ContextLoader { private getAddressFromPath( path: string, - decodedCallData: ethers.utils.Result, - ): HexString { - // ethers.utils.Result is a record string, any + decodedCallData: ethers.Result, + ): HexaString { + // ethers.Result is a record string, any // eslint-disable-next-line @typescript-eslint/no-explicit-any let value: any = decodedCallData; for (const key of path.split(".")) { @@ -155,12 +154,14 @@ export class ExternalPluginContextLoader implements ContextLoader { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access value = value[value.length - 1]; } else { + // This access can throw a RangeError error in case of an invalid key + // but is correctly caught by the liftEither above // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access value = value[key]; } } - if (!isHexString(value)) { + if (!isHexaString(value)) { throw new Error( "[ContextModule] ExternalPluginContextLoader: Unable to get address", ); diff --git a/packages/signer/context-module/src/nft/domain/NftContextLoader.ts b/packages/signer/context-module/src/nft/domain/NftContextLoader.ts index c5e40862f..a8fd3bc44 100644 --- a/packages/signer/context-module/src/nft/domain/NftContextLoader.ts +++ b/packages/signer/context-module/src/nft/domain/NftContextLoader.ts @@ -1,10 +1,10 @@ +import { HexaString, isHexaString } from "@ledgerhq/device-sdk-core"; import { inject, injectable } from "inversify"; import type { NftDataSource } from "@/nft/data/NftDataSource"; import { nftTypes } from "@/nft/di/nftTypes"; import { ContextLoader } from "@/shared/domain/ContextLoader"; import { ClearSignContext } from "@/shared/model/ClearSignContext"; -import { HexString, isHexString } from "@/shared/model/HexString"; import { TransactionContext } from "@/shared/model/TransactionContext"; enum ERC721_SUPPORTED_SELECTOR { @@ -21,7 +21,7 @@ enum ERC1155_SUPPORTED_SELECTOR { SafeBatchTransferFrom = "0x2eb2c2d6", } -const SUPPORTED_SELECTORS: HexString[] = [ +const SUPPORTED_SELECTORS: HexaString[] = [ ...Object.values(ERC721_SUPPORTED_SELECTOR), ...Object.values(ERC1155_SUPPORTED_SELECTOR), ]; @@ -43,7 +43,7 @@ export class NftContextLoader implements ContextLoader { const selector = transaction.data.slice(0, 10); - if (!isHexString(selector)) { + if (!isHexaString(selector)) { return [{ type: "error", error: new Error("Invalid selector") }]; } @@ -98,7 +98,7 @@ export class NftContextLoader implements ContextLoader { return responses; } - private isSelectorSupported(selector: HexString) { + private isSelectorSupported(selector: HexaString) { return Object.values(SUPPORTED_SELECTORS).includes(selector); } } diff --git a/packages/signer/context-module/src/shared/model/HexString.test.ts b/packages/signer/context-module/src/shared/model/HexString.test.ts deleted file mode 100644 index 613d82d9b..000000000 --- a/packages/signer/context-module/src/shared/model/HexString.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { isHexString } from "./HexString"; - -describe("HexString", () => { - describe("isHexString function", () => { - it("should return true if the value is a valid hex string", () => { - // GIVEN - const value = "0x1234abc"; - - // WHEN - const result = isHexString(value); - - // THEN - expect(result).toBeTruthy(); - }); - - it("should return true if no data", () => { - // GIVEN - const value = "0x"; - - // WHEN - const result = isHexString(value); - - // THEN - expect(result).toBeTruthy(); - }); - - it("should return false if the value contain an invalid letter", () => { - // GIVEN - const value = "0x1234z"; - - // WHEN - const result = isHexString(value); - - // THEN - expect(result).toBeFalsy(); - }); - - it("should return false if the value does not start with 0x", () => { - // GIVEN - const value = "1234abc"; - - // WHEN - const result = isHexString(value); - - // THEN - expect(result).toBeFalsy(); - }); - - it("should return false for an epmty string", () => { - // GIVEN - const value = ""; - - // WHEN - const result = isHexString(value); - - // THEN - expect(result).toBeFalsy(); - }); - }); -}); diff --git a/packages/signer/context-module/src/shared/model/HexString.ts b/packages/signer/context-module/src/shared/model/HexString.ts deleted file mode 100644 index 4e1ddad3d..000000000 --- a/packages/signer/context-module/src/shared/model/HexString.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type HexString = `0x${string}`; - -export const isHexString = (value: string): value is HexString => { - return /^0x[0-9a-fA-F]*$/.test(value); -}; diff --git a/packages/signer/context-module/src/shared/model/TypedDataClearSignContext.ts b/packages/signer/context-module/src/shared/model/TypedDataClearSignContext.ts new file mode 100644 index 000000000..a42269ae0 --- /dev/null +++ b/packages/signer/context-module/src/shared/model/TypedDataClearSignContext.ts @@ -0,0 +1,44 @@ +// The general informations for a typed message +export type TypedDataMessageInfo = { + displayName: string; + filtersCount: number; + signature: string; +}; + +// Token index and descriptor. Needed for tokens that are referenced by a typed message +export type TypedDataTokenIndex = number; +export type TypedDataToken = string; +// Special token index value when the referenced token is the verifying contract +export const VERIFYING_CONTRACT_TOKEN_INDEX = 255; + +// Typed message filters, to select fields to display, and provide formatting informations +export type TypedDataFilterPath = string; +export type TypedDataFilter = + | { + type: "datetime" | "raw"; + displayName: string; + path: TypedDataFilterPath; + signature: string; + } + | { + type: "amount" | "token"; + displayName: string; + tokenIndex: TypedDataTokenIndex; + path: TypedDataFilterPath; + signature: string; + }; + +// Clear signing context for a typed message +export type TypedDataClearSignContextSuccess = { + type: "success"; + messageInfo: TypedDataMessageInfo; + filters: Record; + tokens: Record; +}; +export type TypedDataClearSignContextError = { + type: "error"; + error: Error; +}; +export type TypedDataClearSignContext = + | TypedDataClearSignContextSuccess + | TypedDataClearSignContextError; diff --git a/packages/signer/context-module/src/shared/model/TypedDataContext.ts b/packages/signer/context-module/src/shared/model/TypedDataContext.ts new file mode 100644 index 000000000..5f101ed43 --- /dev/null +++ b/packages/signer/context-module/src/shared/model/TypedDataContext.ts @@ -0,0 +1,17 @@ +// The schema of a typed message +export type TypedDataSchema = Record< + string, + Array<{ name: string; type: string }> +>; + +// The extracted message values, with their path +export type TypedDataFieldValues = Array<{ path: string; value: Uint8Array }>; + +// Context needed to fetch the clear signing context of a typed message +export type TypedDataContext = { + verifyingContract: string; + chainId: number; + version: "v1" | "v2"; + schema: TypedDataSchema; + fieldsValues: TypedDataFieldValues; +}; diff --git a/packages/signer/context-module/src/token/domain/TokenContextLoader.ts b/packages/signer/context-module/src/token/domain/TokenContextLoader.ts index c267f68ac..9ce08bf4e 100644 --- a/packages/signer/context-module/src/token/domain/TokenContextLoader.ts +++ b/packages/signer/context-module/src/token/domain/TokenContextLoader.ts @@ -1,8 +1,8 @@ +import { HexaString, isHexaString } from "@ledgerhq/device-sdk-core"; import { inject, injectable } from "inversify"; import { ContextLoader } from "@/shared/domain/ContextLoader"; import { ClearSignContext } from "@/shared/model/ClearSignContext"; -import { HexString, isHexString } from "@/shared/model/HexString"; import { TransactionContext } from "@/shared/model/TransactionContext"; import type { TokenDataSource } from "@/token/data/TokenDataSource"; import { tokenTypes } from "@/token/di/tokenTypes"; @@ -12,7 +12,7 @@ export enum ERC20_SUPPORTED_SELECTORS { Transfer = "0xa9059cbb", } -const SUPPORTED_SELECTORS: HexString[] = Object.values( +const SUPPORTED_SELECTORS: HexaString[] = Object.values( ERC20_SUPPORTED_SELECTORS, ); @@ -31,7 +31,7 @@ export class TokenContextLoader implements ContextLoader { const selector = transaction.data.slice(0, 10); - if (!isHexString(selector)) { + if (!isHexaString(selector)) { return [{ type: "error", error: new Error("Invalid selector") }]; } @@ -55,7 +55,7 @@ export class TokenContextLoader implements ContextLoader { ]; } - private isSelectorSupported(selector: HexString) { + private isSelectorSupported(selector: HexaString) { return Object.values(SUPPORTED_SELECTORS).includes(selector); } } diff --git a/packages/signer/context-module/src/typed-data/data/FiltersDto.ts b/packages/signer/context-module/src/typed-data/data/FiltersDto.ts new file mode 100644 index 000000000..ef3377b61 --- /dev/null +++ b/packages/signer/context-module/src/typed-data/data/FiltersDto.ts @@ -0,0 +1,41 @@ +export type FilterFieldV1 = { + label: string; + path: string; + signature: string; + format?: never; +}; + +export type FilterFieldV2 = { + label: string; + path: string; + signature: string; + format: "raw" | "datetime"; + coin_ref?: never; +}; + +export type FilterFieldV2WithCoinRef = { + label: string; + path: string; + signature: string; + format: "token" | "amount"; + coin_ref: number; +}; + +export type FilterField = + | FilterFieldV1 + | FilterFieldV2 + | FilterFieldV2WithCoinRef; + +export type FiltersDto = { + eip712_signatures: { + [contractAddress: string]: { + [schemaHash: string]: { + contractName: { + label: string; + signature: string; + }; + fields: Array; + }; + }; + }; +}; diff --git a/packages/signer/context-module/src/typed-data/data/HttpTypedDataDataSource.test.ts b/packages/signer/context-module/src/typed-data/data/HttpTypedDataDataSource.test.ts new file mode 100644 index 000000000..8c4154d63 --- /dev/null +++ b/packages/signer/context-module/src/typed-data/data/HttpTypedDataDataSource.test.ts @@ -0,0 +1,613 @@ +import axios from "axios"; +import { Right } from "purify-ts"; + +import { HttpTypedDataDataSource } from "@/typed-data/data/HttpTypedDataDataSource"; +import { type TypedDataDataSource } from "@/typed-data/data/TypedDataDataSource"; +import PACKAGE from "@root/package.json"; + +jest.mock("axios"); + +describe("HttpTypedDataDataSource", () => { + let datasource: TypedDataDataSource; + + const TEST_TYPES = { + PermitSingle: [ + { + name: "details", + type: "PermitDetails", + }, + { + name: "spender", + type: "address", + }, + { + name: "sigDeadline", + type: "uint256", + }, + ], + PermitDetails: [ + { + type: "address", + name: "token", + }, + { + name: "amount", + type: "uint160", + }, + { + name: "expiration", + type: "uint48", + }, + { + name: "nonce", + type: "uint48", + }, + ], + EIP712Domain: [ + { + name: "name", + type: "string", + }, + { + name: "chainId", + type: "uint256", + }, + { + name: "verifyingContract", + type: "address", + }, + ], + }; + + beforeAll(() => { + datasource = new HttpTypedDataDataSource(); + jest.clearAllMocks(); + }); + + it("should call axios with the ledger client version header", async () => { + // GIVEN + const version = `context-module/${PACKAGE.version}`; + const requestSpy = jest.fn(() => Promise.resolve({ data: [] })); + jest.spyOn(axios, "request").mockImplementation(requestSpy); + + // WHEN + await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x00", + version: "v2", + schema: {}, + }); + + // THEN + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { "X-Ledger-Client-Version": version }, + }), + ); + }); + + it("should return V2 filters when axios response is correct", async () => { + // GIVEN + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df3": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: [ + { + coin_ref: 0, + format: "token", + label: "Amount allowance", + path: "details.token", + signature: + "3044022075103b38995e031d1ebbfe38ac6603bec32854b5146a664e49b4cc4f460c1da6022029f4b0fd1f3b7995ffff1627d4b57f27888a2dcc9b3a4e85c37c67571092c733", + }, + { + coin_ref: 0, + format: "amount", + label: "Amount allowance", + path: "details.amount", + signature: + "304402201a46e6b4ef89eaf9fcf4945d053bfc5616a826400fd758312fbbe976bafc07ec022025a9b408722baf983ee053f90179c75b0c55bb0668f437d55493e36069bbd5a3", + }, + { + format: "raw", + label: "Approve to spender", + path: "spender", + signature: + "3044022033e5713d9cb9bc375b56a9fb53b736c81ea3c4ac5cfb2d3ca7f8b8f0558fe2430220543ca4fef6d6f725f29e343f167fe9dd582aa856ecb5797259050eb990a1befb", + }, + { + format: "datetime", + label: "Approval expire", + path: "details.expiration", + signature: + "3044022056b3381e4540629ad73bc434ec49d80523234b82f62340fbb77157fb0eb21a680220459fe9cf6ca309f9c7dfc6d4711fea1848dba661563c57f77b3c2dc480b3a63b", + }, + ], + }, + }, + }, + }, + ]; + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v2", + schema: TEST_TYPES, + }); + + // THEN + expect(result).toEqual( + Right({ + messageInfo: { + displayName: "Permit2", + filtersCount: 4, + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + filters: [ + { + type: "token", + displayName: "Amount allowance", + path: "details.token", + tokenIndex: 0, + signature: + "3044022075103b38995e031d1ebbfe38ac6603bec32854b5146a664e49b4cc4f460c1da6022029f4b0fd1f3b7995ffff1627d4b57f27888a2dcc9b3a4e85c37c67571092c733", + }, + { + type: "amount", + displayName: "Amount allowance", + path: "details.amount", + tokenIndex: 0, + signature: + "304402201a46e6b4ef89eaf9fcf4945d053bfc5616a826400fd758312fbbe976bafc07ec022025a9b408722baf983ee053f90179c75b0c55bb0668f437d55493e36069bbd5a3", + }, + { + type: "raw", + displayName: "Approve to spender", + path: "spender", + signature: + "3044022033e5713d9cb9bc375b56a9fb53b736c81ea3c4ac5cfb2d3ca7f8b8f0558fe2430220543ca4fef6d6f725f29e343f167fe9dd582aa856ecb5797259050eb990a1befb", + }, + { + type: "datetime", + displayName: "Approval expire", + path: "details.expiration", + signature: + "3044022056b3381e4540629ad73bc434ec49d80523234b82f62340fbb77157fb0eb21a680220459fe9cf6ca309f9c7dfc6d4711fea1848dba661563c57f77b3c2dc480b3a63b", + }, + ], + }), + ); + }); + + it("should return V1 filters when axios response is correct", async () => { + // GIVEN + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df3": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: [ + { + label: "Amount allowance", + path: "details.token", + signature: + "3045022100c98bae217208d9ba8e3649163d8ee9ed2f69518b4ab7204dba15eda4b3ff32aa02205f03f9a6fac8ae4eceb6b61703bfd7f27f58a83bf21b2f815aec2ad766ba7009", + }, + { + label: "Amount allowance", + path: "details.amount", + signature: + "3045022100bb9bb0c71678a39ba8ed764a67bae0998b992850b7dd1dfefc2fbb7cf6036b170220041568fbd2f58b4cca4012a48ab3b4ddab54fbbc5280fe854ec92ca92dcd9ded", + }, + { + label: "Approve to spender", + path: "spender", + signature: + "3044022033e5713d9cb9bc375b56a9fb53b736c81ea3c4ac5cfb2d3ca7f8b8f0558fe2430220543ca4fef6d6f725f29e343f167fe9dd582aa856ecb5797259050eb990a1befb", + }, + { + label: "Approval expire", + path: "details.expiration", + signature: + "304502210094deb9cc390f9a507ace0c3b32a33c1a3388960f673e8f4fe019b203c3c4918902206363885ee3b37fe441b50a47de18ae2a4feddf001454dbb93a3800565cc11fa9", + }, + ], + }, + }, + }, + }, + ]; + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result).toEqual( + Right({ + messageInfo: { + displayName: "Permit2", + filtersCount: 4, + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + filters: [ + { + type: "raw", + displayName: "Amount allowance", + path: "details.token", + signature: + "3045022100c98bae217208d9ba8e3649163d8ee9ed2f69518b4ab7204dba15eda4b3ff32aa02205f03f9a6fac8ae4eceb6b61703bfd7f27f58a83bf21b2f815aec2ad766ba7009", + }, + { + type: "raw", + displayName: "Amount allowance", + path: "details.amount", + signature: + "3045022100bb9bb0c71678a39ba8ed764a67bae0998b992850b7dd1dfefc2fbb7cf6036b170220041568fbd2f58b4cca4012a48ab3b4ddab54fbbc5280fe854ec92ca92dcd9ded", + }, + { + type: "raw", + displayName: "Approve to spender", + path: "spender", + signature: + "3044022033e5713d9cb9bc375b56a9fb53b736c81ea3c4ac5cfb2d3ca7f8b8f0558fe2430220543ca4fef6d6f725f29e343f167fe9dd582aa856ecb5797259050eb990a1befb", + }, + { + type: "raw", + displayName: "Approval expire", + path: "details.expiration", + signature: + "304502210094deb9cc390f9a507ace0c3b32a33c1a3388960f673e8f4fe019b203c3c4918902206363885ee3b37fe441b50a47de18ae2a4feddf001454dbb93a3800565cc11fa9", + }, + ], + }), + ); + }); + + it("should return an error when data is empty", async () => { + // GIVEN + jest.spyOn(axios, "request").mockResolvedValue({ data: undefined }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); + + it("should return an error when schema is not found", async () => { + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df4": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: [], + }, + }, + }, + }, + ]; + // GIVEN + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); + + it("should return an error if message info is invalid", async () => { + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df3": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: "should be an array", + }, + }, + }, + }, + ]; + // GIVEN + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); + + it("should return an error if field is invalid", async () => { + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df3": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: ["should be an object"], + }, + }, + }, + }, + ]; + // GIVEN + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); + + it("should return an error if field path is invalid", async () => { + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df3": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: [ + { + label: "Amount allowance", + path: 2, + signature: + "3045022100c98bae217208d9ba8e3649163d8ee9ed2f69518b4ab7204dba15eda4b3ff32aa02205f03f9a6fac8ae4eceb6b61703bfd7f27f58a83bf21b2f815aec2ad766ba7009", + }, + ], + }, + }, + }, + }, + ]; + // GIVEN + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); + + it("should return an error if field label is invalid", async () => { + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df3": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: [ + { + label: 2, + path: "details.token", + signature: + "3045022100c98bae217208d9ba8e3649163d8ee9ed2f69518b4ab7204dba15eda4b3ff32aa02205f03f9a6fac8ae4eceb6b61703bfd7f27f58a83bf21b2f815aec2ad766ba7009", + }, + ], + }, + }, + }, + }, + ]; + // GIVEN + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); + + it("should return an error if field signature is invalid", async () => { + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df3": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: [ + { + label: "Amount allowance", + path: "details.token", + signature: 2, + }, + ], + }, + }, + }, + }, + ]; + // GIVEN + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); + + it("should return an error on raw fields with coin ref", async () => { + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df3": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: [ + { + format: "raw", + label: "Amount allowance", + path: "details.token", + coin_ref: 0, + signature: + "3045022100c98bae217208d9ba8e3649163d8ee9ed2f69518b4ab7204dba15eda4b3ff32aa02205f03f9a6fac8ae4eceb6b61703bfd7f27f58a83bf21b2f815aec2ad766ba7009", + }, + ], + }, + }, + }, + }, + ]; + // GIVEN + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); + + it("should return an error on token fields without coin ref", async () => { + const filtersDTO = [ + { + eip712_signatures: { + "0x000000000022d473030f116ddee9f6b43ac78ba3": { + "4d593149e876e739220f3b5ede1b38a0213d76c4705b1547c4323df3": { + contractName: { + label: "Permit2", + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + fields: [ + { + format: "token", + label: "Amount allowance", + path: "details.token", + signature: + "3045022100c98bae217208d9ba8e3649163d8ee9ed2f69518b4ab7204dba15eda4b3ff32aa02205f03f9a6fac8ae4eceb6b61703bfd7f27f58a83bf21b2f815aec2ad766ba7009", + }, + ], + }, + }, + }, + }, + ]; + // GIVEN + jest.spyOn(axios, "request").mockResolvedValue({ data: filtersDTO }); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); + + it("should return an error when axios throws an error", async () => { + // GIVEN + jest.spyOn(axios, "request").mockRejectedValue(new Error()); + + // WHEN + const result = await datasource.getTypedDataFilters({ + chainId: 1, + address: "0x000000000022d473030f116ddee9f6b43ac78ba3", + version: "v1", + schema: TEST_TYPES, + }); + + // THEN + expect(result.isLeft()).toEqual(true); + }); +}); diff --git a/packages/signer/context-module/src/typed-data/data/HttpTypedDataDataSource.ts b/packages/signer/context-module/src/typed-data/data/HttpTypedDataDataSource.ts new file mode 100644 index 000000000..5253e1f0a --- /dev/null +++ b/packages/signer/context-module/src/typed-data/data/HttpTypedDataDataSource.ts @@ -0,0 +1,174 @@ +import axios from "axios"; +import SHA224 from "crypto-js/sha224"; +import { injectable } from "inversify"; +import { Either, Left, Right } from "purify-ts"; + +import type { + TypedDataFilter, + TypedDataMessageInfo, +} from "@/shared/model/TypedDataClearSignContext"; +import type { TypedDataSchema } from "@/shared/model/TypedDataContext"; +import PACKAGE from "@root/package.json"; + +import type { + FilterField, + FilterFieldV1, + FilterFieldV2, + FilterFieldV2WithCoinRef, + FiltersDto, +} from "./FiltersDto"; +import { + GetTypedDataFiltersParams, + GetTypedDataFiltersResult, + TypedDataDataSource, +} from "./TypedDataDataSource"; + +@injectable() +export class HttpTypedDataDataSource implements TypedDataDataSource { + public async getTypedDataFilters({ + chainId, + address, + schema, + version, + }: GetTypedDataFiltersParams): Promise< + Either + > { + try { + const response = await axios.request({ + method: "GET", + url: `https://crypto-assets-service.api.ledger.com/v1/dapps`, + params: { + contracts: address, + chain_id: chainId, + output: "eip712_signatures", + eip712_signatures_version: version, + }, + headers: { + "X-Ledger-Client-Version": `context-module/${PACKAGE.version}`, + }, + }); + + // Try to get the filters JSON descriptor, from address and schema hash + const schemaHash = SHA224( + JSON.stringify(this.sortTypes(schema)).replace(" ", ""), + ).toString(); + const filtersJson = + response.data?.[0]?.eip712_signatures?.[address]?.[schemaHash]; + if (!filtersJson) { + return Left( + new Error( + `[ContextModule] HttpTypedDataDataSource: no typed data filters for address ${address} on chain ${chainId} for schema ${schemaHash}`, + ), + ); + } + + // Parse the message type, if available + if ( + !filtersJson.contractName || + typeof filtersJson.contractName.label !== "string" || + typeof filtersJson.contractName.signature !== "string" || + !Array.isArray(filtersJson.fields) + ) { + return Left( + new Error( + `[ContextModule] HttpTypedDataDataSource: no message info for address ${address} on chain ${chainId} for schema ${schemaHash}`, + ), + ); + } + const messageInfo: TypedDataMessageInfo = { + displayName: filtersJson.contractName.label, + filtersCount: filtersJson.fields.length, + signature: filtersJson.contractName.signature, + }; + + // Parse all the filters + const filters: TypedDataFilter[] = []; + for (const field of filtersJson.fields) { + if (this.isFieldFilterV1(field)) { + filters.push({ + type: "raw", + displayName: field.label, + path: field.path, + signature: field.signature, + }); + } else if (this.isFieldFilterV2(field)) { + filters.push({ + type: field.format, + displayName: field.label, + path: field.path, + signature: field.signature, + }); + } else if (this.isFieldFilterV2WithCoinRef(field)) { + filters.push({ + type: field.format, + displayName: field.label, + path: field.path, + signature: field.signature, + tokenIndex: field.coin_ref, + }); + } else { + return Left( + new Error( + `[ContextModule] HttpTypedDataDataSource: invalid typed data field for address ${address} on chain ${chainId} for schema ${schemaHash}`, + ), + ); + } + } + + return Right({ messageInfo, filters }); + } catch (error) { + return Left( + new Error( + "[ContextModule] HttpTypedDataDataSource: Failed to fetch typed data informations", + ), + ); + } + } + + private isFieldFilterV1(data: FilterField): data is FilterFieldV1 { + return ( + typeof data === "object" && + typeof data.label === "string" && + typeof data.path === "string" && + typeof data.signature === "string" && + (data.format === undefined || data.format === null) + ); + } + + private isFieldFilterV2(data: FilterField): data is FilterFieldV2 { + return ( + typeof data === "object" && + typeof data.label === "string" && + typeof data.path === "string" && + typeof data.signature === "string" && + typeof data.format === "string" && + ["raw", "datetime"].includes(data.format) && + (data.coin_ref === undefined || data.coin_ref === null) + ); + } + + private isFieldFilterV2WithCoinRef( + data: FilterField, + ): data is FilterFieldV2WithCoinRef { + return ( + typeof data === "object" && + typeof data.label === "string" && + typeof data.path === "string" && + typeof data.signature === "string" && + typeof data.format === "string" && + ["token", "amount"].includes(data.format) && + typeof data.coin_ref === "number" + ); + } + + private sortTypes(types: TypedDataSchema): TypedDataSchema { + return Object.fromEntries( + Object.entries(types) + .sort(([aKey], [bKey]) => aKey.localeCompare(bKey)) + .map(([key, value]) => [ + key, + value.map((v) => ({ name: v.name, type: v.type })), + ]), + ); + } +} diff --git a/packages/signer/context-module/src/typed-data/data/TypedDataDataSource.ts b/packages/signer/context-module/src/typed-data/data/TypedDataDataSource.ts new file mode 100644 index 000000000..e80f7c59f --- /dev/null +++ b/packages/signer/context-module/src/typed-data/data/TypedDataDataSource.ts @@ -0,0 +1,25 @@ +import { Either } from "purify-ts"; + +import { + TypedDataFilter, + TypedDataMessageInfo, +} from "@/shared/model/TypedDataClearSignContext"; +import { TypedDataSchema } from "@/shared/model/TypedDataContext"; + +export type GetTypedDataFiltersParams = { + address: string; + chainId: number; + version: "v1" | "v2"; + schema: TypedDataSchema; +}; + +export type GetTypedDataFiltersResult = { + messageInfo: TypedDataMessageInfo; + filters: TypedDataFilter[]; +}; + +export interface TypedDataDataSource { + getTypedDataFilters( + params: GetTypedDataFiltersParams, + ): Promise>; +} diff --git a/packages/signer/context-module/src/typed-data/di/typedDataModuleFactory.ts b/packages/signer/context-module/src/typed-data/di/typedDataModuleFactory.ts new file mode 100644 index 000000000..1b1fb2cb8 --- /dev/null +++ b/packages/signer/context-module/src/typed-data/di/typedDataModuleFactory.ts @@ -0,0 +1,13 @@ +import { ContainerModule } from "inversify"; + +import { HttpTypedDataDataSource } from "@/typed-data/data/HttpTypedDataDataSource"; +import { typedDataTypes } from "@/typed-data/di/typedDataTypes"; +import { DefaultTypedDataContextLoader } from "@/typed-data/domain/DefaultTypedDataContextLoader"; + +export const typedDataModuleFactory = () => + new ContainerModule((bind, _unbind, _isBound, _rebind) => { + bind(typedDataTypes.TypedDataDataSource).to(HttpTypedDataDataSource); + bind(typedDataTypes.TypedDataContextLoader).to( + DefaultTypedDataContextLoader, + ); + }); diff --git a/packages/signer/context-module/src/typed-data/di/typedDataTypes.ts b/packages/signer/context-module/src/typed-data/di/typedDataTypes.ts new file mode 100644 index 000000000..43538e9fd --- /dev/null +++ b/packages/signer/context-module/src/typed-data/di/typedDataTypes.ts @@ -0,0 +1,4 @@ +export const typedDataTypes = { + TypedDataDataSource: Symbol.for("TypedDataDataSource"), + TypedDataContextLoader: Symbol.for("TypedDataContextLoader"), +}; diff --git a/packages/signer/context-module/src/typed-data/domain/DefaultTypedDataContextLoader.test.ts b/packages/signer/context-module/src/typed-data/domain/DefaultTypedDataContextLoader.test.ts new file mode 100644 index 000000000..49a48124e --- /dev/null +++ b/packages/signer/context-module/src/typed-data/domain/DefaultTypedDataContextLoader.test.ts @@ -0,0 +1,403 @@ +import { Left, Right } from "purify-ts"; + +import type { TypedDataContext } from "@/shared/model/TypedDataContext"; +import type { TokenDataSource } from "@/token/data/TokenDataSource"; +import type { TypedDataDataSource } from "@/typed-data/data/TypedDataDataSource"; +import { DefaultTypedDataContextLoader } from "@/typed-data/domain/DefaultTypedDataContextLoader"; + +describe("TokenContextLoader", () => { + const mockTokenDataSource: TokenDataSource = { + getTokenInfosPayload: jest.fn(), + }; + const mockTypedDataDataSource: TypedDataDataSource = { + getTypedDataFilters: jest.fn(), + }; + const loader = new DefaultTypedDataContextLoader( + mockTypedDataDataSource, + mockTokenDataSource, + ); + + const TEST_TYPES = { + PermitSingle: [ + { + name: "details", + type: "PermitDetails", + }, + { + name: "spender", + type: "address", + }, + { + name: "sigDeadline", + type: "uint256", + }, + ], + PermitDetails: [ + { + type: "address", + name: "token", + }, + { + name: "amount", + type: "uint160", + }, + { + name: "expiration", + type: "uint48", + }, + { + name: "nonce", + type: "uint48", + }, + ], + EIP712Domain: [ + { + name: "name", + type: "string", + }, + { + name: "chainId", + type: "uint256", + }, + { + name: "verifyingContract", + type: "address", + }, + ], + }; + const TEST_VALUES = [ + { + path: "details.token", + value: Uint8Array.from([ + 0x7c, 0xeb, 0x23, 0xfd, 0x6b, 0xc0, 0xad, 0xd5, 0x9e, 0x62, 0xac, 0x25, + 0x57, 0x82, 0x70, 0xcf, 0xf1, 0xb9, 0xf6, 0x19, + ]), + }, + { + path: "details.amount", + value: Uint8Array.from([0x12]), + }, + { + path: "spender", + value: Uint8Array.from([0x12]), + }, + { + path: "details.expiration", + value: Uint8Array.from([0x12]), + }, + ]; + + beforeEach(() => { + jest.restoreAllMocks(); + jest + .spyOn(mockTokenDataSource, "getTokenInfosPayload") + .mockImplementation(({ address }) => + Promise.resolve(Right(`payload-${address}`)), + ); + }); + + describe("load function", () => { + it("success with referenced token", async () => { + // GIVEN + const ctx = { + verifyingContract: "0x000000000022d473030f116ddee9f6b43ac78ba3", + chainId: 1, + version: "v2", + schema: TEST_TYPES, + fieldsValues: TEST_VALUES, + } as TypedDataContext; + jest + .spyOn(mockTypedDataDataSource, "getTypedDataFilters") + .mockImplementation(() => + Promise.resolve( + Right({ + messageInfo: { + displayName: "Permit2", + filtersCount: 4, + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + filters: [ + { + type: "token", + displayName: "Amount allowance", + path: "details.token", + tokenIndex: 0, + signature: + "3044022075103b38995e031d1ebbfe38ac6603bec32854b5146a664e49b4cc4f460c1da6022029f4b0fd1f3b7995ffff1627d4b57f27888a2dcc9b3a4e85c37c67571092c733", + }, + { + type: "amount", + displayName: "Amount allowance", + path: "details.amount", + tokenIndex: 0, + signature: + "304402201a46e6b4ef89eaf9fcf4945d053bfc5616a826400fd758312fbbe976bafc07ec022025a9b408722baf983ee053f90179c75b0c55bb0668f437d55493e36069bbd5a3", + }, + { + type: "raw", + displayName: "Approve to spender", + path: "spender", + signature: + "3044022033e5713d9cb9bc375b56a9fb53b736c81ea3c4ac5cfb2d3ca7f8b8f0558fe2430220543ca4fef6d6f725f29e343f167fe9dd582aa856ecb5797259050eb990a1befb", + }, + { + type: "datetime", + displayName: "Approval expire", + path: "details.expiration", + signature: + "3044022056b3381e4540629ad73bc434ec49d80523234b82f62340fbb77157fb0eb21a680220459fe9cf6ca309f9c7dfc6d4711fea1848dba661563c57f77b3c2dc480b3a63b", + }, + ], + }), + ), + ); + + // WHEN + const result = await loader.load(ctx); + + // THEN + expect(result).toEqual({ + type: "success", + messageInfo: { + displayName: "Permit2", + filtersCount: 4, + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + tokens: { + 0: "payload-0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", + }, + filters: { + "details.amount": { + displayName: "Amount allowance", + path: "details.amount", + signature: + "304402201a46e6b4ef89eaf9fcf4945d053bfc5616a826400fd758312fbbe976bafc07ec022025a9b408722baf983ee053f90179c75b0c55bb0668f437d55493e36069bbd5a3", + tokenIndex: 0, + type: "amount", + }, + "details.expiration": { + displayName: "Approval expire", + path: "details.expiration", + signature: + "3044022056b3381e4540629ad73bc434ec49d80523234b82f62340fbb77157fb0eb21a680220459fe9cf6ca309f9c7dfc6d4711fea1848dba661563c57f77b3c2dc480b3a63b", + type: "datetime", + }, + "details.token": { + displayName: "Amount allowance", + path: "details.token", + signature: + "3044022075103b38995e031d1ebbfe38ac6603bec32854b5146a664e49b4cc4f460c1da6022029f4b0fd1f3b7995ffff1627d4b57f27888a2dcc9b3a4e85c37c67571092c733", + tokenIndex: 0, + type: "token", + }, + spender: { + displayName: "Approve to spender", + path: "spender", + signature: + "3044022033e5713d9cb9bc375b56a9fb53b736c81ea3c4ac5cfb2d3ca7f8b8f0558fe2430220543ca4fef6d6f725f29e343f167fe9dd582aa856ecb5797259050eb990a1befb", + type: "raw", + }, + }, + }); + }); + + it("success with referenced token verifying contract", async () => { + // GIVEN + const ctx = { + verifyingContract: "0x000000000022d473030f116ddee9f6b43ac78ba3", + chainId: 1, + version: "v2", + schema: TEST_TYPES, + fieldsValues: TEST_VALUES, + } as TypedDataContext; + jest + .spyOn(mockTypedDataDataSource, "getTypedDataFilters") + .mockImplementation(() => + Promise.resolve( + Right({ + messageInfo: { + displayName: "Permit2", + filtersCount: 2, + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + filters: [ + { + type: "token", + displayName: "Amount allowance", + path: "details.token", + tokenIndex: 0, + signature: + "3044022075103b38995e031d1ebbfe38ac6603bec32854b5146a664e49b4cc4f460c1da6022029f4b0fd1f3b7995ffff1627d4b57f27888a2dcc9b3a4e85c37c67571092c733", + }, + { + type: "amount", + displayName: "Amount allowance", + path: "details.amount", + tokenIndex: 255, + signature: + "304402201a46e6b4ef89eaf9fcf4945d053bfc5616a826400fd758312fbbe976bafc07ec022025a9b408722baf983ee053f90179c75b0c55bb0668f437d55493e36069bbd5a3", + }, + ], + }), + ), + ); + + // WHEN + const result = await loader.load(ctx); + + // THEN + expect(result).toEqual({ + type: "success", + messageInfo: { + displayName: "Permit2", + filtersCount: 2, + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + tokens: { + 0: "payload-0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", + 255: "payload-0x000000000022d473030f116ddee9f6b43ac78ba3", + }, + filters: { + "details.amount": { + displayName: "Amount allowance", + path: "details.amount", + signature: + "304402201a46e6b4ef89eaf9fcf4945d053bfc5616a826400fd758312fbbe976bafc07ec022025a9b408722baf983ee053f90179c75b0c55bb0668f437d55493e36069bbd5a3", + tokenIndex: 255, + type: "amount", + }, + "details.token": { + displayName: "Amount allowance", + path: "details.token", + signature: + "3044022075103b38995e031d1ebbfe38ac6603bec32854b5146a664e49b4cc4f460c1da6022029f4b0fd1f3b7995ffff1627d4b57f27888a2dcc9b3a4e85c37c67571092c733", + tokenIndex: 0, + type: "token", + }, + }, + }); + }); + + it("should return an error if filters are unavailable", async () => { + // GIVEN + const ctx = { + verifyingContract: "0x000000000022d473030f116ddee9f6b43ac78ba3", + chainId: 1, + version: "v2", + schema: TEST_TYPES, + fieldsValues: TEST_VALUES, + } as TypedDataContext; + jest + .spyOn(mockTypedDataDataSource, "getTypedDataFilters") + .mockImplementation(() => Promise.resolve(Left(new Error("error")))); + + // WHEN + const result = await loader.load(ctx); + + // THEN + expect(result).toEqual({ + type: "error", + error: new Error("error"), + }); + }); + + it("should return an error if tokens are unavailable", async () => { + // GIVEN + const ctx = { + verifyingContract: "0x000000000022d473030f116ddee9f6b43ac78ba3", + chainId: 1, + version: "v2", + schema: TEST_TYPES, + fieldsValues: TEST_VALUES, + } as TypedDataContext; + jest + .spyOn(mockTypedDataDataSource, "getTypedDataFilters") + .mockImplementation(() => + Promise.resolve( + Right({ + messageInfo: { + displayName: "Permit2", + filtersCount: 2, + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + filters: [ + { + type: "token", + displayName: "Amount allowance", + path: "details.token", + tokenIndex: 0, + signature: + "3044022075103b38995e031d1ebbfe38ac6603bec32854b5146a664e49b4cc4f460c1da6022029f4b0fd1f3b7995ffff1627d4b57f27888a2dcc9b3a4e85c37c67571092c733", + }, + ], + }), + ), + ); + jest + .spyOn(mockTokenDataSource, "getTokenInfosPayload") + .mockImplementation(() => + Promise.resolve(Left(new Error("token error"))), + ); + + // WHEN + const result = await loader.load(ctx); + + // THEN + expect(result).toEqual({ + type: "error", + error: new Error("token error"), + }); + }); + + it("should return an error if value is not found", async () => { + // GIVEN + const ctx = { + verifyingContract: "0x000000000022d473030f116ddee9f6b43ac78ba3", + chainId: 1, + version: "v2", + schema: TEST_TYPES, + fieldsValues: TEST_VALUES, + } as TypedDataContext; + jest + .spyOn(mockTypedDataDataSource, "getTypedDataFilters") + .mockImplementation(() => + Promise.resolve( + Right({ + messageInfo: { + displayName: "Permit2", + filtersCount: 2, + signature: + "3045022100e3c597d13d28a87a88b0239404c668373cf5063362f2a81d09eed4582941dfe802207669aabb504fd5b95b2734057f6b8bbf51f14a69a5f9bdf658a5952cefbf44d3", + }, + filters: [ + { + type: "token", + displayName: "Amount allowance", + path: "details.badtoken", + tokenIndex: 0, + signature: + "3044022075103b38995e031d1ebbfe38ac6603bec32854b5146a664e49b4cc4f460c1da6022029f4b0fd1f3b7995ffff1627d4b57f27888a2dcc9b3a4e85c37c67571092c733", + }, + ], + }), + ), + ); + + // WHEN + const result = await loader.load(ctx); + + // THEN + expect(result).toEqual({ + type: "error", + error: new Error( + "The token filter references the value details.badtoken which is absent from the message", + ), + }); + }); + }); +}); diff --git a/packages/signer/context-module/src/typed-data/domain/DefaultTypedDataContextLoader.ts b/packages/signer/context-module/src/typed-data/domain/DefaultTypedDataContextLoader.ts new file mode 100644 index 000000000..6629848ad --- /dev/null +++ b/packages/signer/context-module/src/typed-data/domain/DefaultTypedDataContextLoader.ts @@ -0,0 +1,136 @@ +import type { HexaString } from "@ledgerhq/device-sdk-core"; +import { inject, injectable } from "inversify"; + +import type { + TypedDataClearSignContext, + TypedDataFilter, + TypedDataFilterPath, + TypedDataToken, + TypedDataTokenIndex, +} from "@/shared/model/TypedDataClearSignContext"; +import { VERIFYING_CONTRACT_TOKEN_INDEX } from "@/shared/model/TypedDataClearSignContext"; +import type { TypedDataContext } from "@/shared/model/TypedDataContext"; +import type { TokenDataSource } from "@/token/data/TokenDataSource"; +import { tokenTypes } from "@/token/di/tokenTypes"; +import type { TypedDataDataSource } from "@/typed-data/data/TypedDataDataSource"; +import { typedDataTypes } from "@/typed-data/di/typedDataTypes"; +import type { TypedDataContextLoader } from "@/typed-data/domain/TypedDataContextLoader"; + +@injectable() +export class DefaultTypedDataContextLoader implements TypedDataContextLoader { + constructor( + @inject(typedDataTypes.TypedDataDataSource) + private dataSource: TypedDataDataSource, + @inject(tokenTypes.TokenDataSource) + private tokenDataSource: TokenDataSource, + ) {} + + async load(typedData: TypedDataContext): Promise { + // Get the typed data filters from the data source + const data = await this.dataSource.getTypedDataFilters({ + address: typedData.verifyingContract, + chainId: typedData.chainId, + version: typedData.version, + schema: typedData.schema, + }); + + // If there was an error getting the typed data filters, return an error immediately + if (data.isLeft()) { + return { + type: "error", + error: data.extract(), + }; + } + + // Else, extract the message info and filters + const { messageInfo, filters } = data.unsafeCoerce(); + + // Loop through the typed data filters to extract informations + const mappedFilters: Record = {}; + const mappedTokens: Record = {}; + for (const filter of filters) { + // Add the filter to the clear signing context + mappedFilters[filter.path] = filter; + if (filter.type !== "token" && filter.type !== "amount") { + continue; // no token reference + } + + // If the filter references a token, retrieve its descriptor from the tokens data source + const tokenIndex = filter.tokenIndex; + if (mappedTokens[tokenIndex] !== undefined) { + continue; // Already fetched for a previous filter + } + + // If the filter is a token, get token address from typed message values, and fetch descriptor + if (filter.type === "token") { + const value = typedData.fieldsValues.find( + (entry) => entry.path === filter.path, + ); + if (value === undefined) { + return { + type: "error", + error: new Error( + `The token filter references the value ${filter.path} which is absent from the message`, + ), + }; + } + // Fetch descriptor + const address = this.convertAddressToHexaString(value.value); + const chainId = typedData.chainId; + const payload = await this.tokenDataSource.getTokenInfosPayload({ + address, + chainId, + }); + if (payload.isLeft()) { + return { + type: "error", + error: payload.extract(), + }; + } + payload.ifRight((payload) => { + mappedTokens[tokenIndex] = payload; + }); + } + + // If the filter is an amount with a reference to the verifyingContract, fetch verifyingContract descriptor. + // This is because descriptors data-sources should be compatible with Ledger devices specifications: + // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#amount-join-value + else if ( + filter.type === "amount" && + tokenIndex === VERIFYING_CONTRACT_TOKEN_INDEX + ) { + const address = typedData.verifyingContract; + const chainId = typedData.chainId; + const payload = await this.tokenDataSource.getTokenInfosPayload({ + address, + chainId, + }); + if (payload.isLeft()) { + return { + type: "error", + error: payload.extract(), + }; + } + payload.ifRight((payload) => { + mappedTokens[tokenIndex] = payload; + }); + } + } + + return { + type: "success", + messageInfo, + filters: mappedFilters, + tokens: mappedTokens, + }; + } + + private convertAddressToHexaString(address: Uint8Array): HexaString { + // Address size is 20 bytes so 40 characters, padded with zeros on the left + return `0x${Array.from(address, (byte) => + byte.toString(16).padStart(2, "0"), + ) + .join("") + .padStart(40, "0")}`; + } +} diff --git a/packages/signer/context-module/src/typed-data/domain/TypedDataContextLoader.ts b/packages/signer/context-module/src/typed-data/domain/TypedDataContextLoader.ts new file mode 100644 index 000000000..a75e84362 --- /dev/null +++ b/packages/signer/context-module/src/typed-data/domain/TypedDataContextLoader.ts @@ -0,0 +1,6 @@ +import type { TypedDataClearSignContext } from "@/shared/model/TypedDataClearSignContext"; +import type { TypedDataContext } from "@/shared/model/TypedDataContext"; + +export interface TypedDataContextLoader { + load(typedData: TypedDataContext): Promise; +} diff --git a/packages/signer/context-module/tsconfig.cjs.json b/packages/signer/context-module/tsconfig.cjs.json index a797cc717..3797ab02f 100644 --- a/packages/signer/context-module/tsconfig.cjs.json +++ b/packages/signer/context-module/tsconfig.cjs.json @@ -2,8 +2,8 @@ "extends": "./tsconfig.json", "exclude": ["src/**/*.test.ts", "jest.*.ts"], "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", + "module": "nodenext", + "moduleResolution": "nodenext", "outDir": "./lib/cjs", "declarationDir": "./lib/cjs" } diff --git a/packages/signer/keyring-eth/jest.config.ts b/packages/signer/keyring-eth/jest.config.ts index ac318d966..175e4572f 100644 --- a/packages/signer/keyring-eth/jest.config.ts +++ b/packages/signer/keyring-eth/jest.config.ts @@ -1,5 +1,11 @@ /* eslint no-restricted-syntax: 0 */ -import type { JestConfigWithTsJest } from "ts-jest"; +import { JestConfigWithTsJest, pathsToModuleNameMapper } from "ts-jest"; + +import { compilerOptions } from "./tsconfig.json"; + +const paths = pathsToModuleNameMapper(compilerOptions.paths, { + prefix: "/", +}); const config: JestConfigWithTsJest = { preset: "@ledgerhq/jest-config-dsdk", @@ -12,9 +18,7 @@ const config: JestConfigWithTsJest = { "!src/api/index.ts", ], moduleNameMapper: { - "^@api/(.*)$": "/src/api/$1", - "^@internal/(.*)$": "/src/internal/$1", - "^@root/(.*)$": "/$1", + ...paths, }, }; diff --git a/packages/signer/keyring-eth/package.json b/packages/signer/keyring-eth/package.json index 63f37483e..5c3631c05 100644 --- a/packages/signer/keyring-eth/package.json +++ b/packages/signer/keyring-eth/package.json @@ -42,8 +42,8 @@ "test:coverage": "pnpm test -- --coverage" }, "dependencies": { - "ethers-v5": "npm:ethers@v5", - "ethers-v6": "npm:ethers@v6", + "ethers-v5": "npm:ethers@^5.7.2", + "ethers-v6": "npm:ethers@^6.13.2", "inversify": "^6.0.2", "inversify-logger-middleware": "^3.1.0", "purify-ts": "^2.1.0", @@ -56,8 +56,8 @@ "@ledgerhq/jest-config-dsdk": "workspace:*", "@ledgerhq/prettier-config-dsdk": "workspace:*", "@ledgerhq/tsconfig-dsdk": "workspace:*", - "ts-node": "^10.9.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "ts-node": "^10.9.2" }, "peerDependencies": { "@ledgerhq/context-module": "workspace:*", diff --git a/packages/signer/keyring-eth/scripts/build.mjs b/packages/signer/keyring-eth/scripts/build.mjs index b319316e1..c58b550c9 100644 --- a/packages/signer/keyring-eth/scripts/build.mjs +++ b/packages/signer/keyring-eth/scripts/build.mjs @@ -19,4 +19,5 @@ const run = async () => Promise.all([buildEsm(), builCjs()]); run().catch((e) => { console.error(e); + process.exitCode = e.exitCode; }); diff --git a/packages/signer/keyring-eth/src/api/model/Address.ts b/packages/signer/keyring-eth/src/api/model/Address.ts index adbd893fa..3439d7040 100644 --- a/packages/signer/keyring-eth/src/api/model/Address.ts +++ b/packages/signer/keyring-eth/src/api/model/Address.ts @@ -1,5 +1,7 @@ +import { HexaString } from "@ledgerhq/device-sdk-core"; + export type Address = { - address: `0x${string}`; + address: HexaString; publicKey: string; chainCode?: string; }; diff --git a/packages/signer/keyring-eth/src/api/model/Signature.ts b/packages/signer/keyring-eth/src/api/model/Signature.ts index c7b0656eb..c261c060a 100644 --- a/packages/signer/keyring-eth/src/api/model/Signature.ts +++ b/packages/signer/keyring-eth/src/api/model/Signature.ts @@ -1 +1,3 @@ -export type Signature = { r: `0x${string}`; s: `0x${string}`; v: number }; +import { HexaString } from "@ledgerhq/device-sdk-core"; + +export type Signature = { r: HexaString; s: HexaString; v: number }; diff --git a/packages/signer/keyring-eth/src/api/model/TypedData.ts b/packages/signer/keyring-eth/src/api/model/TypedData.ts index 997c6da70..8a7deb362 100644 --- a/packages/signer/keyring-eth/src/api/model/TypedData.ts +++ b/packages/signer/keyring-eth/src/api/model/TypedData.ts @@ -1 +1,21 @@ -export type TypedData = unknown; +// As defined in https://eips.ethereum.org/EIPS/eip-712 + +export interface TypedData { + domain: TypedDataDomain; + types: Record>; + primaryType: string; + message: Record; +} + +export interface TypedDataDomain { + name?: string; + version?: string; + chainId?: number; + verifyingContract?: string; + salt?: string; +} + +export interface TypedDataField { + name: string; + type: string; +} diff --git a/packages/signer/keyring-eth/src/index.ts b/packages/signer/keyring-eth/src/index.ts index 4a2ede760..d1f800da1 100644 --- a/packages/signer/keyring-eth/src/index.ts +++ b/packages/signer/keyring-eth/src/index.ts @@ -1 +1,4 @@ +// inversify requirement +import "reflect-metadata"; + export * from "@api/index"; diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/GetAddressCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/GetAddressCommand.ts index 866f7cfa9..318b3cfc4 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/GetAddressCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/GetAddressCommand.ts @@ -7,12 +7,14 @@ import { type Command, CommandUtils, InvalidStatusWordError, + isHexaString, } from "@ledgerhq/device-sdk-core"; import { GetAddressCommandArgs, GetAddressCommandResponse, } from "@api/app-binder/GetAddressCommandTypes"; +import { DerivationPathUtils } from "@internal/shared/utils/DerivationPathUtils"; const CHAIN_CODE_LENGTH = 32; @@ -35,7 +37,7 @@ export class GetAddressCommand const builder = new ApduBuilder(getEthAddressArgs); const derivationPath = this.args.derivationPath; - const path = this.splitPath(derivationPath); + const path = DerivationPathUtils.splitPath(derivationPath); builder.add8BitUIntToData(path.length); path.forEach((element) => { builder.add32BitUIntToData(element); @@ -82,10 +84,16 @@ export class GetAddressCommand throw new InvalidStatusWordError("Ethereum address is missing"); } - const address = parser.encodeToString( + const result = parser.encodeToString( parser.extractFieldByLength(addressLength), ); + const address = `0x${result}`; + + if (isHexaString(address) === false) { + throw new InvalidStatusWordError("Invalid Ethereum address"); + } + let chainCode = undefined; if (this.args.returnChainCode) { if (parser.testMinimalLength(CHAIN_CODE_LENGTH) === false) { @@ -99,24 +107,8 @@ export class GetAddressCommand return { publicKey, - address: `0x${address}`, + address, chainCode, }; } - - private splitPath(path: string): number[] { - const result: number[] = []; - const components = path.split("/"); - components.forEach((element) => { - let number = parseInt(element, 10); - if (isNaN(number)) { - return; // FIXME: shouldn't it throws instead? - } - if (element.length > 1 && element[element.length - 1] === "'") { - number += 0x80000000; - } - result.push(number); - }); - return result; - } } diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideDomainNameCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideDomainNameCommand.test.ts new file mode 100644 index 000000000..d79f29e2b --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideDomainNameCommand.test.ts @@ -0,0 +1,64 @@ +import { + ApduResponse, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +import { + ProvideDomainNameCommand, + ProvideDomainNameCommandArgs, +} from "./ProvideDomainNameCommand"; + +const FIRST_CHUNK_APDU = Uint8Array.from([ + 0xe0, 0x22, 0x01, 0x00, 0x08, 0x00, 0x06, 0x4c, 0x65, 0x64, 0x67, 0x65, 0x72, +]); + +describe("ProvideDomainNameCommand", () => { + describe("getApdu", () => { + it("should return the raw APDU", () => { + // GIVEN + const args: ProvideDomainNameCommandArgs = { + data: "00064C6564676572", + index: 0, + }; + // WHEN + const command = new ProvideDomainNameCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(FIRST_CHUNK_APDU); + }); + }); + + describe("parseResponse", () => { + it("should throw an error if the response status code is invalid", () => { + // GIVEN + const response: ApduResponse = { + data: Buffer.from([]), + statusCode: Buffer.from([0x6a, 0x80]), // Invalid status code + }; + // WHEN + const command = new ProvideDomainNameCommand({ + data: "", + index: 0, + }); + // THEN + expect(() => command.parseResponse(response)).toThrow( + InvalidStatusWordError, + ); + }); + + it("should not throw if the response status code is correct", () => { + // GIVEN + const response: ApduResponse = { + data: Buffer.from([]), + statusCode: Buffer.from([0x90, 0x00]), // Success status code + }; + // WHEN + const command = new ProvideDomainNameCommand({ + data: "", + index: 0, + }); + // THEN + expect(() => command.parseResponse(response)).not.toThrow(); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideDomainNameCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideDomainNameCommand.ts index 08f4ffa79..93fb189d5 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideDomainNameCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideDomainNameCommand.ts @@ -1,2 +1,60 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#provide-domain-name -export class ProvideDomainNameCommand {} +import { + Apdu, + ApduBuilder, + type ApduBuilderArgs, + ApduParser, + ApduResponse, + type Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +export type ProvideDomainNameCommandArgs = { + /** + * The chunk of the stringified hexa representation of the domain name prefixed by its length in two bytes. + * If the index equals 0, the first two bytes are the length of the domain name, else all the bytes are the chunk data. + * @example "00064C6564676572" (hexa for "Ledger", first chunk and only chunk) + */ + data: string; + /** + * The index of the chunk. + */ + index: number; +}; + +/** + * The command that provides a chunk of the domain name to the device. + */ +export class ProvideDomainNameCommand + implements Command +{ + constructor(private args: ProvideDomainNameCommandArgs) {} + + getApdu(): Apdu { + const isFirstChunk = this.args.index === 0; + const apduBuilderArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x22, + p1: isFirstChunk ? 0x01 : 0x00, + p2: 0x00, + }; + + return new ApduBuilder(apduBuilderArgs) + .addHexaStringToData(this.args.data) + .build(); + } + + parseResponse(response: ApduResponse): void { + const parser = new ApduParser(response); + + // TODO: handle the error correctly using a generic error handler + if (!CommandUtils.isSuccessResponse(response)) { + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + response.statusCode, + )}`, + ); + } + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideNFTInformationCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideNFTInformationCommand.test.ts new file mode 100644 index 000000000..7cfa21176 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideNFTInformationCommand.test.ts @@ -0,0 +1,69 @@ +import { + ApduResponse, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +import { + ProvideNFTInformationCommand, + ProvideNFTInformationCommandArgs, +} from "./ProvideNFTInformationCommand"; + +const NFT_INFORMATION_PAYLOAD = + "0101084d6574614d6f6a61c5b07a55501014f36ec5d39d950a321439f6dd7600000000000000010101473045022100d5f96cad91b83da224c94945e4c8aeb54f089f52c87302af54f0b6b74159f76a02201a1204a36b15f2ff31149fd05502ad65ee98fe77f30a3c8d9b32eb6cf08cabea"; + +const NFT_INFORMATION_APDU = Uint8Array.from([ + 0xe0, 0x14, 0x00, 0x00, 0x71, 0x01, 0x01, 0x08, 0x4d, 0x65, 0x74, 0x61, 0x4d, + 0x6f, 0x6a, 0x61, 0xc5, 0xb0, 0x7a, 0x55, 0x50, 0x10, 0x14, 0xf3, 0x6e, 0xc5, + 0xd3, 0x9d, 0x95, 0x0a, 0x32, 0x14, 0x39, 0xf6, 0xdd, 0x76, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x47, 0x30, 0x45, 0x02, 0x21, 0x00, + 0xd5, 0xf9, 0x6c, 0xad, 0x91, 0xb8, 0x3d, 0xa2, 0x24, 0xc9, 0x49, 0x45, 0xe4, + 0xc8, 0xae, 0xb5, 0x4f, 0x08, 0x9f, 0x52, 0xc8, 0x73, 0x02, 0xaf, 0x54, 0xf0, + 0xb6, 0xb7, 0x41, 0x59, 0xf7, 0x6a, 0x02, 0x20, 0x1a, 0x12, 0x04, 0xa3, 0x6b, + 0x15, 0xf2, 0xff, 0x31, 0x14, 0x9f, 0xd0, 0x55, 0x02, 0xad, 0x65, 0xee, 0x98, + 0xfe, 0x77, 0xf3, 0x0a, 0x3c, 0x8d, 0x9b, 0x32, 0xeb, 0x6c, 0xf0, 0x8c, 0xab, + 0xea, +]); + +describe("ProvideNFTInformationCommand", () => { + describe("getApdu", () => { + it("should return the raw APDU", () => { + // GIVEN + const args: ProvideNFTInformationCommandArgs = { + data: NFT_INFORMATION_PAYLOAD, + }; + // WHEN + const command = new ProvideNFTInformationCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(NFT_INFORMATION_APDU); + }); + }); + + describe("parseResponse", () => { + it("should throw an error if the response status code is invalid", () => { + // GIVEN + const response: ApduResponse = { + data: Buffer.from([]), + statusCode: Buffer.from([0x6d, 0x00]), // Invalid status code + }; + // WHEN + const command = new ProvideNFTInformationCommand({ data: "" }); + // THEN + expect(() => command.parseResponse(response)).toThrow( + InvalidStatusWordError, + ); + }); + + it("should not throw if the response status code is correct", () => { + // GIVEN + const response: ApduResponse = { + data: Buffer.from([]), + statusCode: Buffer.from([0x90, 0x00]), // Success status code + }; + // WHEN + const command = new ProvideNFTInformationCommand({ data: "" }); + // THEN + expect(() => command.parseResponse(response)).not.toThrow(); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideNFTInformationCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideNFTInformationCommand.ts index 06a11abf5..e9d1fdcbe 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideNFTInformationCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideNFTInformationCommand.ts @@ -1,2 +1,55 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#provide-nft-information -export class ProvideNFTInformationCommand {} +import { + Apdu, + ApduBuilder, + type ApduBuilderArgs, + ApduParser, + ApduResponse, + type Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +export type ProvideNFTInformationCommandArgs = { + /** + * The stringified hexa representation of the NFT data. + */ + data: string; +}; + +export class ProvideNFTInformationCommand + implements Command +{ + constructor(private args: ProvideNFTInformationCommandArgs) {} + + getApdu(): Apdu { + const apduBuilderArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x14, + p1: 0x00, + p2: 0x00, + }; + + return new ApduBuilder(apduBuilderArgs) + .addHexaStringToData(this.args.data) + .build(); + } + + parseResponse(response: ApduResponse): void { + const parser = new ApduParser(response); + + if (response.statusCode[0] === 0x6d && response.statusCode[1] === 0x00) { + // This is temporary, a new error class should be created to handle this case later. + throw new InvalidStatusWordError("ETH app is not up to date"); + } + + if (!CommandUtils.isSuccessResponse(response)) { + // TODO: handle the error correctly using a generic error handler + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + response.statusCode, + )}`, + ); + } + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideTokenInformationCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideTokenInformationCommand.test.ts new file mode 100644 index 000000000..2929d379a --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideTokenInformationCommand.test.ts @@ -0,0 +1,100 @@ +import { Command, InvalidStatusWordError } from "@ledgerhq/device-sdk-core"; + +import { + ProvideTokenInformationCommand, + ProvideTokenInformationCommandArgs, +} from "./ProvideTokenInformationCommand"; + +const PAYLOAD_USDT = + "0455534454dac17f958d2ee523a2206206994597c13d831ec700000006000000013044022078c66ccea3e4dedb15a24ec3c783d7b582cd260daf62fd36afe9a8212a344aed0220160ba8c1c4b6a8aa6565bed20632a091aeeeb7bfdac67fc6589a6031acbf511c"; + +const PAYLOAD_USDC = + "0455534443a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000006000000013045022100b2e358726e4e6a6752cf344017c0e9d45b9a904120758d45f61b2804f9ad5299022015161ef28d8c4481bd9432c13562def9cce688bcfec896ef244c9a213f106cdd"; + +const PROVIDE_TOKEN_INFORMATION_APDU_USDT = Uint8Array.from([ + 0xe0, 0x0a, 0x00, 0x00, 0x67, 0x04, 0x55, 0x53, 0x44, 0x54, 0xda, 0xc1, 0x7f, + 0x95, 0x8d, 0x2e, 0xe5, 0x23, 0xa2, 0x20, 0x62, 0x06, 0x99, 0x45, 0x97, 0xc1, + 0x3d, 0x83, 0x1e, 0xc7, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x30, + 0x44, 0x02, 0x20, 0x78, 0xc6, 0x6c, 0xce, 0xa3, 0xe4, 0xde, 0xdb, 0x15, 0xa2, + 0x4e, 0xc3, 0xc7, 0x83, 0xd7, 0xb5, 0x82, 0xcd, 0x26, 0x0d, 0xaf, 0x62, 0xfd, + 0x36, 0xaf, 0xe9, 0xa8, 0x21, 0x2a, 0x34, 0x4a, 0xed, 0x02, 0x20, 0x16, 0x0b, + 0xa8, 0xc1, 0xc4, 0xb6, 0xa8, 0xaa, 0x65, 0x65, 0xbe, 0xd2, 0x06, 0x32, 0xa0, + 0x91, 0xae, 0xee, 0xb7, 0xbf, 0xda, 0xc6, 0x7f, 0xc6, 0x58, 0x9a, 0x60, 0x31, + 0xac, 0xbf, 0x51, 0x1c, +]); + +const PROVIDE_TOKEN_INFORMATION_APDU_USDC = Uint8Array.from([ + 0xe0, 0x0a, 0x00, 0x00, 0x68, 0x04, 0x55, 0x53, 0x44, 0x43, 0xa0, 0xb8, 0x69, + 0x91, 0xc6, 0x21, 0x8b, 0x36, 0xc1, 0xd1, 0x9d, 0x4a, 0x2e, 0x9e, 0xb0, 0xce, + 0x36, 0x06, 0xeb, 0x48, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x30, + 0x45, 0x02, 0x21, 0x00, 0xb2, 0xe3, 0x58, 0x72, 0x6e, 0x4e, 0x6a, 0x67, 0x52, + 0xcf, 0x34, 0x40, 0x17, 0xc0, 0xe9, 0xd4, 0x5b, 0x9a, 0x90, 0x41, 0x20, 0x75, + 0x8d, 0x45, 0xf6, 0x1b, 0x28, 0x04, 0xf9, 0xad, 0x52, 0x99, 0x02, 0x20, 0x15, + 0x16, 0x1e, 0xf2, 0x8d, 0x8c, 0x44, 0x81, 0xbd, 0x94, 0x32, 0xc1, 0x35, 0x62, + 0xde, 0xf9, 0xcc, 0xe6, 0x88, 0xbc, 0xfe, 0xc8, 0x96, 0xef, 0x24, 0x4c, 0x9a, + 0x21, 0x3f, 0x10, 0x6c, 0xdd, +]); + +describe("ProvideTokenInformationCommand", () => { + let command: Command; + + describe("getApdu", () => { + it("should return the apdu for usdt payload", () => { + // GIVEN + command = new ProvideTokenInformationCommand({ payload: PAYLOAD_USDT }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + PROVIDE_TOKEN_INFORMATION_APDU_USDT, + ); + }); + + it("should return the apdu for usdc payload", () => { + // GIVEN + command = new ProvideTokenInformationCommand({ payload: PAYLOAD_USDC }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + PROVIDE_TOKEN_INFORMATION_APDU_USDC, + ); + }); + }); + + describe("parseResponse", () => { + it("should parse the response", () => { + // GIVEN + const response = { + statusCode: Uint8Array.from([0x90, 0x00]), + data: new Uint8Array(), + }; + + // WHEN + const parsedResponse = command.parseResponse(response); + + // THEN + expect(parsedResponse).toBeUndefined(); + }); + + it("should throw an error if the response is not successful", () => { + // GIVEN + const response = { + statusCode: Uint8Array.from([0x55, 0x15]), + data: new Uint8Array(), + }; + + // WHEN + const promise = () => command.parseResponse(response); + + // THEN + expect(() => { + promise(); + }).toThrow(InvalidStatusWordError); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideTokenInformationCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideTokenInformationCommand.ts index b13ecf731..7881bfee6 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideTokenInformationCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/ProvideTokenInformationCommand.ts @@ -1,2 +1,50 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#provide-erc-20-token-information -export class ProvideTokenInformationCommand {} +import { + Apdu, + ApduBuilder, + ApduBuilderArgs, + ApduParser, + ApduResponse, + Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +export type ProvideTokenInformationCommandArgs = { + payload: string; +}; + +export class ProvideTokenInformationCommand + implements Command +{ + args: ProvideTokenInformationCommandArgs; + + constructor(args: ProvideTokenInformationCommandArgs) { + this.args = args; + } + + getApdu(): Apdu { + const getEthAddressArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x0a, + p1: 0x00, + p2: 0x00, + }; + const builder = new ApduBuilder(getEthAddressArgs); + builder.addHexaStringToData(this.args.payload); + return builder.build(); + } + + parseResponse(response: ApduResponse): void { + const parser = new ApduParser(response); + + // TODO: handle the error correctly using a generic error handler + if (!CommandUtils.isSuccessResponse(response)) { + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + response.statusCode, + )}`, + ); + } + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712FilteringCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712FilteringCommand.test.ts new file mode 100644 index 000000000..d2b3985f3 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712FilteringCommand.test.ts @@ -0,0 +1,183 @@ +import { + ApduResponse, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +import { + Eip712FilterType, + SendEIP712FilteringCommand, + SendEIP712FilteringCommandArgs, +} from "./SendEIP712FilteringCommand"; + +const ACTIVATE_APDU = Uint8Array.from([0xe0, 0x1e, 0x00, 0x00, 0x00]); +const MESSAGE_INFO_APDU = Uint8Array.from([ + 0xe0, 0x1e, 0x00, 0x0f, 0x54, 0x0b, 0x31, 0x69, 0x6e, 0x63, 0x68, 0x20, 0x4f, + 0x72, 0x64, 0x65, 0x72, 0x06, 0x46, 0x30, 0x44, 0x02, 0x20, 0x29, 0x5e, 0x0a, + 0xeb, 0x17, 0xca, 0x09, 0x29, 0xb2, 0xa9, 0x4c, 0x32, 0x4d, 0x67, 0xd0, 0xb5, + 0x52, 0x8a, 0xba, 0x26, 0x81, 0x77, 0xf3, 0xac, 0x29, 0x7b, 0x56, 0x31, 0x41, + 0xe0, 0x00, 0x27, 0x02, 0x20, 0x3a, 0xc3, 0x60, 0xd9, 0xfd, 0x0c, 0x9c, 0x0c, + 0x12, 0x27, 0x9d, 0x1e, 0x73, 0xbe, 0xa5, 0xd5, 0x49, 0xa1, 0xe8, 0x14, 0x1f, + 0x45, 0x4d, 0x88, 0xfb, 0xe1, 0xe8, 0xef, 0x97, 0x0e, 0x68, 0x02, +]); +const RAW_APDU = Uint8Array.from([ + 0xe0, 0x1e, 0x00, 0xff, 0x4d, 0x04, 0x46, 0x72, 0x6f, 0x6d, 0x47, 0x30, 0x45, + 0x02, 0x21, 0x00, 0xb8, 0x20, 0xe4, 0xdf, 0xb1, 0xa0, 0xcd, 0xe6, 0xdc, 0x97, + 0xd9, 0xa3, 0x4e, 0xeb, 0xb1, 0xa4, 0xee, 0xf0, 0xb2, 0x26, 0x26, 0x2e, 0x67, + 0x88, 0x11, 0x8a, 0xb3, 0xc7, 0xfb, 0x79, 0xfe, 0x35, 0x02, 0x20, 0x2d, 0x42, + 0x6a, 0x38, 0x8b, 0x4c, 0x3a, 0x80, 0x96, 0xb3, 0xf8, 0x44, 0x12, 0xa7, 0x02, + 0xea, 0x53, 0x77, 0x70, 0xe6, 0x1e, 0xe0, 0x72, 0x7e, 0xc1, 0xb7, 0x10, 0xc1, + 0xda, 0x52, 0x0c, 0x44, +]); +const TOKEN_APDU = Uint8Array.from([ + 0xe0, 0x1e, 0x00, 0xfd, 0x49, 0x01, 0x47, 0x30, 0x45, 0x02, 0x21, 0x00, 0xff, + 0x72, 0x78, 0x47, 0x44, 0x54, 0x31, 0xe5, 0x71, 0xcd, 0x2a, 0x0d, 0x9d, 0xb4, + 0x2a, 0x7e, 0xb6, 0x2e, 0x37, 0x87, 0x7b, 0x9b, 0xf2, 0x0e, 0x6a, 0x96, 0x58, + 0x42, 0x55, 0x34, 0x7e, 0x19, 0x02, 0x20, 0x0a, 0x6e, 0x95, 0xb7, 0xf8, 0xe6, + 0x3b, 0x2f, 0xab, 0x0b, 0xef, 0x88, 0xc7, 0x47, 0xde, 0x6a, 0x38, 0x7d, 0x06, + 0x35, 0x1b, 0xe5, 0xbd, 0xc3, 0x4b, 0x2c, 0x1f, 0x9a, 0xea, 0x6f, 0xdd, 0x28, +]); +const AMOUNT_APDU = Uint8Array.from([ + 0xe0, 0x1e, 0x00, 0xfe, 0x59, 0x0f, 0x52, 0x65, 0x63, 0x65, 0x69, 0x76, 0x65, + 0x20, 0x6d, 0x69, 0x6e, 0x69, 0x6d, 0x75, 0x6d, 0x01, 0x47, 0x30, 0x45, 0x02, + 0x21, 0x00, 0xa5, 0x9d, 0xc4, 0x79, 0xa8, 0x38, 0xa8, 0x13, 0x90, 0x9c, 0x14, + 0x0a, 0x15, 0xe6, 0xb6, 0x5b, 0xc5, 0x8c, 0x56, 0x33, 0x28, 0x4b, 0xf9, 0x73, + 0xc4, 0x36, 0xde, 0x5a, 0x59, 0x26, 0x34, 0xe2, 0x02, 0x20, 0x1e, 0x03, 0x8f, + 0xc7, 0x99, 0x5d, 0x93, 0x9f, 0xcc, 0xd5, 0x46, 0xe4, 0xc8, 0x5e, 0x79, 0x3c, + 0x0a, 0xd4, 0x51, 0x21, 0x6e, 0x36, 0xa4, 0xed, 0xfc, 0x7b, 0xce, 0x5b, 0xe2, + 0x78, 0x08, 0xcb, +]); +const DATE_TIME_APDU = Uint8Array.from([ + 0xe0, 0x1e, 0x00, 0xfc, 0x58, 0x0f, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, + 0x6c, 0x20, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x47, 0x30, 0x45, 0x02, 0x21, + 0x00, 0xe8, 0x47, 0x16, 0x6e, 0x60, 0xf8, 0x51, 0xe3, 0xc8, 0xd1, 0xf4, 0x41, + 0x39, 0x81, 0x18, 0x98, 0xcc, 0xd0, 0xd3, 0xa0, 0x3a, 0xed, 0x6c, 0x77, 0xf8, + 0xc3, 0x99, 0x38, 0x13, 0xf4, 0x79, 0xd2, 0x02, 0x20, 0x31, 0xfe, 0x6b, 0x6a, + 0x57, 0x4b, 0x56, 0xc5, 0x10, 0x40, 0x03, 0xcf, 0x07, 0x90, 0x0d, 0x11, 0xff, + 0xaf, 0x30, 0x3d, 0xc0, 0x16, 0xda, 0x4c, 0x1c, 0x3d, 0x18, 0x46, 0x63, 0xda, + 0x8f, 0x6a, +]); + +describe("SendEIP712FilteringCommand", () => { + describe("getApdu", () => { + it("Activate APDU", () => { + // GIVEN + const args: SendEIP712FilteringCommandArgs = { + type: Eip712FilterType.Activation, + }; + // WHEN + const command = new SendEIP712FilteringCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(ACTIVATE_APDU); + }); + + it("Message info APDU", () => { + // GIVEN + const args: SendEIP712FilteringCommandArgs = { + type: Eip712FilterType.MessageInfo, + displayName: "1inch Order", + filtersCount: 6, + signature: + "30440220295e0aeb17ca0929b2a94c324d67d0b5528aba268177f3ac297b563141e0002702203ac360d9fd0c9c0c12279d1e73bea5d549a1e8141f454d88fbe1e8ef970e6802", + }; + // WHEN + const command = new SendEIP712FilteringCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(MESSAGE_INFO_APDU); + }); + + it("Raw APDU", () => { + // GIVEN + const args: SendEIP712FilteringCommandArgs = { + type: Eip712FilterType.Raw, + displayName: "From", + signature: + "3045022100b820e4dfb1a0cde6dc97d9a34eebb1a4eef0b226262e6788118ab3c7fb79fe3502202d426a388b4c3a8096b3f84412a702ea537770e61ee0727ec1b710c1da520c44", + }; + // WHEN + const command = new SendEIP712FilteringCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(RAW_APDU); + }); + + it("Token APDU", () => { + // GIVEN + const args: SendEIP712FilteringCommandArgs = { + type: Eip712FilterType.Token, + tokenIndex: 1, + signature: + "3045022100ff727847445431e571cd2a0d9db42a7eb62e37877b9bf20e6a96584255347e1902200a6e95b7f8e63b2fab0bef88c747de6a387d06351be5bdc34b2c1f9aea6fdd28", + }; + // WHEN + const command = new SendEIP712FilteringCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(TOKEN_APDU); + }); + + it("Amount APDU", () => { + // GIVEN + const args: SendEIP712FilteringCommandArgs = { + type: Eip712FilterType.Amount, + displayName: "Receive minimum", + tokenIndex: 1, + signature: + "3045022100a59dc479a838a813909c140a15e6b65bc58c5633284bf973c436de5a592634e202201e038fc7995d939fccd546e4c85e793c0ad451216e36a4edfc7bce5be27808cb", + }; + // WHEN + const command = new SendEIP712FilteringCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(AMOUNT_APDU); + }); + + it("Date-time APDU", () => { + // GIVEN + const args: SendEIP712FilteringCommandArgs = { + type: Eip712FilterType.Datetime, + displayName: "Approval expire", + signature: + "3045022100e847166e60f851e3c8d1f44139811898ccd0d3a03aed6c77f8c3993813f479d2022031fe6b6a574b56c5104003cf07900d11ffaf303dc016da4c1c3d184663da8f6a", + }; + // WHEN + const command = new SendEIP712FilteringCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(DATE_TIME_APDU); + }); + }); + + describe("parseResponse", () => { + it("should throw an error if the response status code is invalid", () => { + // GIVEN + const response: ApduResponse = { + statusCode: Buffer.from([0x6a, 0x80]), // Invalid status code + data: Buffer.from([]), + }; + // WHEN + const command = new SendEIP712FilteringCommand({ + type: Eip712FilterType.Activation, + }); + // THEN + expect(() => command.parseResponse(response)).toThrow( + InvalidStatusWordError, + ); + }); + + it("should parse the response", () => { + // GIVEN + const response: ApduResponse = { + statusCode: Buffer.from([0x90, 0x00]), // Success status code + data: Buffer.from([]), + }; + // WHEN + const command = new SendEIP712FilteringCommand({ + type: Eip712FilterType.Activation, + }); + // THEN + expect(() => command.parseResponse(response)).not.toThrow(); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712FilteringCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712FilteringCommand.ts new file mode 100644 index 000000000..708e1d209 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712FilteringCommand.ts @@ -0,0 +1,101 @@ +// https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#eip712-filtering +import { + Apdu, + ApduBuilder, + type ApduBuilderArgs, + ApduParser, + ApduResponse, + type Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +export enum Eip712FilterType { + Activation = "activation", + MessageInfo = "message_info", + Datetime = "datetime", + Raw = "raw", + Amount = "amount", + Token = "token", +} + +export type SendEIP712FilteringCommandArgs = + | { type: Eip712FilterType.Activation } + | { + type: Eip712FilterType.MessageInfo; + displayName: string; + filtersCount: number; + signature: string; + } + | { type: Eip712FilterType.Datetime; displayName: string; signature: string } + | { type: Eip712FilterType.Token; tokenIndex: number; signature: string } + | { type: Eip712FilterType.Raw; displayName: string; signature: string } + | { + type: Eip712FilterType.Amount; + displayName: string; + tokenIndex: number; + signature: string; + }; + +const FILTER_TO_P2: Record = { + [Eip712FilterType.Activation]: 0x00, + [Eip712FilterType.MessageInfo]: 0x0f, + [Eip712FilterType.Datetime]: 0xfc, + [Eip712FilterType.Token]: 0xfd, + [Eip712FilterType.Amount]: 0xfe, + [Eip712FilterType.Raw]: 0xff, +}; + +export class SendEIP712FilteringCommand + implements Command +{ + constructor(private readonly args: SendEIP712FilteringCommandArgs) {} + + getApdu(): Apdu { + const filteringArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x1e, + p1: 0x00, + p2: FILTER_TO_P2[this.args.type], + }; + const builder = new ApduBuilder(filteringArgs); + + if (this.args.type === Eip712FilterType.MessageInfo) { + builder + .encodeInLVFromAscii(this.args.displayName) + .add8BitUIntToData(this.args.filtersCount) + .encodeInLVFromHexa(this.args.signature); + } else if ( + this.args.type === Eip712FilterType.Datetime || + this.args.type === Eip712FilterType.Raw + ) { + builder + .encodeInLVFromAscii(this.args.displayName) + .encodeInLVFromHexa(this.args.signature); + } else if ( + this.args.type === Eip712FilterType.Token || + this.args.type === Eip712FilterType.Amount + ) { + if (this.args.type === Eip712FilterType.Amount) { + builder.encodeInLVFromAscii(this.args.displayName); + } + builder + .add8BitUIntToData(this.args.tokenIndex) + .encodeInLVFromHexa(this.args.signature); + } + + return builder.build(); + } + + parseResponse(apduResponse: ApduResponse): void { + const parser = new ApduParser(apduResponse); + + if (!CommandUtils.isSuccessResponse(apduResponse)) { + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + apduResponse.statusCode, + )}`, + ); + } + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructDefinitionCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructDefinitionCommand.test.ts new file mode 100644 index 000000000..95b48fbfe --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructDefinitionCommand.test.ts @@ -0,0 +1,374 @@ +import { Command, InvalidStatusWordError } from "@ledgerhq/device-sdk-core"; +import { Just, Nothing } from "purify-ts"; + +import { + ArrayType, + PrimitiveType, + StructType, +} from "@internal/typed-data/model/Types"; + +import { + SendEIP712StructDefinitionCommand, + SendEIP712StructDefinitionCommandArgs, + StructDefinitionCommand, +} from "./SendEIP712StructDefinitionCommand"; + +const EIP712_DEF_NAME_EIP712DOMAIN = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0x00, 0x0c, 0x45, 0x49, 0x50, 0x37, 0x31, 0x32, 0x44, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, +]); + +const EIP712_DEF_NAME_GROUP = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0x00, 0x05, 0x47, 0x72, 0x6f, 0x75, 0x70, +]); + +// name string +const EIP712_DEF_FIELD_NAME_STRING = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x06, 0x05, 0x04, 0x6e, 0x61, 0x6d, 0x65, +]); + +// version string +const EIP712_DEF_FIELD_VERSION_STRING = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x09, 0x05, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, +]); + +// chainId uint256 +const EIP712_DEF_FIELD_CHAINID_UINT256 = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x0a, 0x42, 0x20, 0x07, 0x63, 0x68, 0x61, 0x69, 0x6e, + 0x49, 0x64, +]); + +// verifyingContract address +const EIP712_DEF_FIELD_VERIFYINGCONTRACT_ADDRESS = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x13, 0x03, 0x11, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, + 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, +]); + +// members Person[] +const EIP712_DEF_FIELD_MEMBERS_PERSON = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x12, 0x80, 0x06, 0x50, 0x65, 0x72, 0x73, 0x6f, 0x6e, + 0x01, 0x00, 0x07, 0x6d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, +]); + +// from Person +const EIP712_DEF_FIELD_FROM_PERSON = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x0d, 0x00, 0x06, 0x50, 0x65, 0x72, 0x73, 0x6f, 0x6e, + 0x04, 0x66, 0x72, 0x6f, 0x6d, +]); + +// wallets address[] +const EIP712_DEF_FIELD_WALLETS_ADDRESS = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x0b, 0x83, 0x01, 0x00, 0x07, 0x77, 0x61, 0x6c, 0x6c, + 0x65, 0x74, 0x73, +]); + +// staticExtradata bytes +const EIP712_DEF_FIELD_STATICEXTRADATA_BYTES = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x11, 0x07, 0x0f, 0x73, 0x74, 0x61, 0x74, 0x69, 0x63, + 0x45, 0x78, 0x74, 0x72, 0x61, 0x64, 0x61, 0x74, 0x61, +]); + +// replacementPattern bytes +const EIP712_DEF_FIELD_REPLACEMENTPATTERN_BYTES = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x14, 0x07, 0x12, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x50, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, +]); + +// dataType bytes4 +const EIP712_DEF_FIELD_DATA_TYPE_BYTES4 = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x0b, 0x46, 0x04, 0x08, 0x64, 0x61, 0x74, 0x61, 0x54, + 0x79, 0x70, 0x65, +]); + +// document string[3][] +const EIP712_DEF_FIELD_DOCUMENT_STRING = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x0e, 0x85, 0x02, 0x01, 0x03, 0x00, 0x08, 0x64, 0x6f, + 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, +]); + +// depthy uint8[][][][] +const EIP712_DEF_FIELD_DEPTHY_UINT8 = Uint8Array.from([ + 0xe0, 0x1a, 0x00, 0xff, 0x0e, 0xc2, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, 0x06, + 0x64, 0x65, 0x70, 0x74, 0x68, 0x79, +]); + +// TODO: find examples for bool and int types. + +describe("SendEIP712StructDefinitionCommand", () => { + let command: Command; + + describe("getApdu", () => { + it("should return the apdu for 'EIP712Domain' name definition", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Name, + name: "EIP712Domain", + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_NAME_EIP712DOMAIN); + }); + + it("should return the apdu for 'Group' name definition", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Name, + name: "Group", + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_NAME_GROUP); + }); + + it("should return the apdu for 'name' of type 'string'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "name", + type: new PrimitiveType("string", "string", Nothing), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_FIELD_NAME_STRING); + }); + + it("should return the apdu for 'version' of type 'string'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "version", + type: new PrimitiveType("string", "string", Nothing), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_FIELD_VERSION_STRING); + }); + + it("should return the apdu for 'chainId' of type 'uint256'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "chainId", + type: new PrimitiveType("uint256", "uint", Just(32)), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_FIELD_CHAINID_UINT256); + }); + + it("should return the apdu for 'verifyingContract' of type 'address'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "verifyingContract", + type: new PrimitiveType("address", "address", Nothing), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + EIP712_DEF_FIELD_VERIFYINGCONTRACT_ADDRESS, + ); + }); + + it("should return the apdu for 'members' of type 'Person[]'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "members", + type: new ArrayType( + "Person[]", + new StructType("Person"), + "Person", + Nothing, + [Nothing], + ), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_FIELD_MEMBERS_PERSON); + }); + + it("should return the apdu for 'from' of type 'Person'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "from", + type: new StructType("Person"), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_FIELD_FROM_PERSON); + }); + + it("should return the apdu for 'wallets' of type 'address[]'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "wallets", + type: new ArrayType( + "address[]", + new PrimitiveType("address", "address", Nothing), + "address", + Nothing, + [Nothing], + ), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_FIELD_WALLETS_ADDRESS); + }); + + it("should return the apdu for 'staticExtradata' of type 'bytes'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "staticExtradata", + type: new PrimitiveType("bytes", "bytes", Nothing), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + EIP712_DEF_FIELD_STATICEXTRADATA_BYTES, + ); + }); + + it("should return the apdu for 'replacementPattern' of type 'bytes'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "replacementPattern", + type: new PrimitiveType("bytes", "bytes", Nothing), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + EIP712_DEF_FIELD_REPLACEMENTPATTERN_BYTES, + ); + }); + + it("should return the apdu for 'dataType' of type 'bytes4'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "dataType", + type: new PrimitiveType("bytes4", "bytes", Just(4)), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + EIP712_DEF_FIELD_DATA_TYPE_BYTES4, + ); + }); + + it("should return the apdu for 'document' of type 'string[3][]'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "document", + type: new ArrayType( + "string[3][]", + new PrimitiveType("string", "string", Nothing), + "string[3]", + Nothing, + [Just(3), Nothing], + ), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_FIELD_DOCUMENT_STRING); + }); + + it("should return the apdu for 'depthy' of type 'uint8[][][][]'", () => { + // GIVEN + command = new SendEIP712StructDefinitionCommand({ + command: StructDefinitionCommand.Field, + name: "depthy", + type: new ArrayType( + "uint8[][][][]", + new PrimitiveType("uint8", "uint", Just(1)), + "uint8[][][]", + Nothing, + [Nothing, Nothing, Nothing, Nothing], + ), + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual(EIP712_DEF_FIELD_DEPTHY_UINT8); + }); + }); + + describe("parseResponse", () => { + it("should parse the response", () => { + // GIVEN + const response = { + statusCode: Uint8Array.from([0x90, 0x00]), + data: new Uint8Array(), + }; + + // WHEN + const parsedResponse = command.parseResponse(response); + + // THEN + expect(parsedResponse).toBeUndefined(); + }); + + it("should throw an error if the response is not successful", () => { + // GIVEN + const response = { + statusCode: Uint8Array.from([0x55, 0x15]), + data: new Uint8Array(), + }; + + // WHEN + const promise = () => command.parseResponse(response); + + // THEN + expect(() => { + promise(); + }).toThrow(InvalidStatusWordError); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructDefinitionCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructDefinitionCommand.ts index 18f0e558a..de8b6f34c 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructDefinitionCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructDefinitionCommand.ts @@ -1,2 +1,168 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#eip712-send-struct-definition -export class SendEIP712StructDefinitionCommand {} +import { + Apdu, + ApduBuilder, + type ApduBuilderArgs, + ApduParser, + ApduResponse, + type Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; +import { Just, Maybe, Nothing } from "purify-ts"; + +import { + ArrayType, + type FieldName, + type FieldType, + PrimitiveType, + StructType, +} from "@internal/typed-data/model/Types"; + +export enum StructDefinitionCommand { + Name = 0, + Field = 255, +} + +export type SendEIP712StructDefinitionCommandArgs = + | { command: StructDefinitionCommand.Name; name: string } + | { + command: StructDefinitionCommand.Field; + name: FieldName; + type: FieldType; + }; + +enum ArraySize { + Dynamic, + Fixed, +} + +enum Type { + Custom, + Int, + Uint, + Address, + Bool, + String, + FixedSizedBytes, + DynamicSizedBytes, +} + +export class SendEIP712StructDefinitionCommand + implements Command +{ + constructor(private args: SendEIP712StructDefinitionCommandArgs) {} + + getApdu(): Apdu { + const SendEIP712StructDefinitionArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x1a, + p1: 0x00, + p2: this.args.command, + }; + + // Struct name + if (this.args.command === StructDefinitionCommand.Name) { + return new ApduBuilder(SendEIP712StructDefinitionArgs) + .addAsciiStringToData(this.args.name) + .build(); + } + + // Struct field + const builder = new ApduBuilder(SendEIP712StructDefinitionArgs); + + const typeDesc = this.constructTypeDescByte(this.args.type); + + // Add type descriptor + builder.add8BitUIntToData(typeDesc); + + // Add struct name if this is a custom type + this.getTypeCustomName(this.args.type).ifJust((customName) => { + builder.encodeInLVFromAscii(customName); + }); + + // Add type size, if applicable + this.getTypeSize(this.args.type).ifJust((size) => { + builder.add8BitUIntToData(size); + }); + + // Add array levels, if it is an array + if (this.args.type instanceof ArrayType) { + builder.add8BitUIntToData(this.args.type.levels.length); + for (const level of this.args.type.levels) { + level.caseOf({ + Just: (l) => { + builder.add8BitUIntToData(ArraySize.Fixed).add8BitUIntToData(l); + }, + Nothing: () => { + builder.add8BitUIntToData(ArraySize.Dynamic); + }, + }); + } + } + + // Add field name + return builder.encodeInLVFromAscii(this.args.name).build(); + } + + parseResponse(response: ApduResponse): void { + const parser = new ApduParser(response); + + // TODO: handle the error correctly using a generic error handler + if (!CommandUtils.isSuccessResponse(response)) { + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + response.statusCode, + )}`, + ); + } + } + + private constructTypeDescByte(type: FieldType): number { + const isArrayBit = type instanceof ArrayType ? 1 : 0; + const hasTypeSize = this.getTypeSize(type).isJust() ? 1 : 0; + const typeBits = this.getType(type); + + // Combine the bits using bitwise operations + const combinedBits = (isArrayBit << 7) | (hasTypeSize << 6) | typeBits; + return combinedBits; + } + + private getTypeSize(type: FieldType): Maybe { + if (type instanceof ArrayType) { + return this.getTypeSize(type.rootType); + } + return type instanceof PrimitiveType ? type.size : Nothing; + } + + private getTypeCustomName(type: FieldType): Maybe { + if (type instanceof ArrayType) { + return this.getTypeCustomName(type.rootType); + } + return type instanceof StructType ? Just(type.typeName) : Nothing; + } + + private getType(type: FieldType): Type { + if (type instanceof ArrayType) { + return this.getType(type.rootType); + } else if (type instanceof StructType) { + return Type.Custom; + } + switch (type.name) { + case "int": + return Type.Int; + case "uint": + return Type.Uint; + case "address": + return Type.Address; + case "bool": + return Type.Bool; + case "string": + return Type.String; + case "bytes": + return type.size.isJust() + ? Type.FixedSizedBytes + : Type.DynamicSizedBytes; + } + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructImplemCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructImplemCommand.test.ts new file mode 100644 index 000000000..6849e3ba2 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructImplemCommand.test.ts @@ -0,0 +1,108 @@ +import { + SendEIP712StructImplemCommand, + type SendEIP712StructImplemCommandArgs, + StructImplemType, +} from "./SendEIP712StructImplemCommand"; + +const ROOT_APDU = Uint8Array.from([ + 0xe0, 0x1c, 0x00, 0x00, 0x06, 0x6c, 0x65, 0x64, 0x67, 0x65, 0x72, +]); + +const ARRAY_APDU = Uint8Array.from([0xe0, 0x1c, 0x00, 0x0f, 0x01, 0x13]); + +const FIELD_LAST_CHUNK_APDU = Uint8Array.from([ + 0xe0, 0x1c, 0x00, 0xff, 0x06, 0x00, 0x04, 0x01, 0x02, 0x03, 0x04, +]); + +const FIELD_OTHER_CHUNK_APDU = Uint8Array.from([ + 0xe0, 0x1c, 0x01, 0xff, 0x05, 0x05, 0x06, 0x07, 0x08, 0x09, +]); + +describe("SendEIP712StructImplemCommand", () => { + describe("getApdu", () => { + it("should return the correct APDU for ROOT", () => { + // GIVEN + const args: SendEIP712StructImplemCommandArgs = { + type: StructImplemType.ROOT, + value: "ledger", + }; + // WHEN + const command = new SendEIP712StructImplemCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(ROOT_APDU); + }); + it("should return the correct APDU for ARRAY", () => { + // GIVEN + const args: SendEIP712StructImplemCommandArgs = { + type: StructImplemType.ARRAY, + value: 19, + }; + // WHEN + const command = new SendEIP712StructImplemCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(ARRAY_APDU); + }); + it("should return the correct APDU for FIELD when receiving last chunk", () => { + // GIVEN + const args: SendEIP712StructImplemCommandArgs = { + type: StructImplemType.FIELD, + value: { + data: Uint8Array.from([0x00, 0x04, 0x01, 0x02, 0x03, 0x04]), + isLastChunk: true, + }, + }; + // WHEN + const command = new SendEIP712StructImplemCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(FIELD_LAST_CHUNK_APDU); + }); + it("should return the correct APDU for FIELD when receiving other chunk", () => { + // GIVEN + const args: SendEIP712StructImplemCommandArgs = { + type: StructImplemType.FIELD, + value: { + data: Uint8Array.from([0x05, 0x06, 0x07, 0x08, 0x09]), + isLastChunk: false, + }, + }; + // WHEN + const command = new SendEIP712StructImplemCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(FIELD_OTHER_CHUNK_APDU); + }); + }); + describe("parseResponse", () => { + it("should throw an error if the response status code is not success", () => { + // GIVEN + const response = { + data: new Uint8Array(), + statusCode: new Uint8Array([0x6a, 0x80]), + }; + // WHEN + const command = new SendEIP712StructImplemCommand({ + type: StructImplemType.ROOT, + value: "ledger", + }); + // THEN + expect(() => command.parseResponse(response)).toThrow(); + }); + it("should not throw an error if the response status code is success", () => { + // GIVEN + const response = { + data: new Uint8Array(), + statusCode: new Uint8Array([0x90, 0x00]), + }; + // WHEN + const command = new SendEIP712StructImplemCommand({ + type: StructImplemType.ROOT, + value: "ledger", + }); + // THEN + expect(() => command.parseResponse(response)).not.toThrow(); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructImplemCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructImplemCommand.ts index 269b39a68..fbb163f47 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructImplemCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SendEIP712StructImplemCommand.ts @@ -1,2 +1,81 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#eip712-send-struct-implementation -export class SendEIP712StructImplemCommand {} +import { + Apdu, + ApduBuilder, + type ApduBuilderArgs, + ApduParser, + ApduResponse, + type Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +export enum StructImplemType { + ROOT = 0x00, + ARRAY = 0x0f, + FIELD = 0xff, +} + +export type SendEIP712StructImplemCommandArgs = + | { + type: StructImplemType.ROOT; + value: string; + } + | { + type: StructImplemType.ARRAY; + value: number; + } + | { + type: StructImplemType.FIELD; + value: { + /** + * The chunk of the data that is ready to send, that is to say, prefixed by its length in two bytes. + * Eg. 01020304 => [0x00, 0x04, 0x01, 0x02, 0x03, 0x04] where 0x00, 0x04 are the length of the data. + */ + data: Uint8Array; + isLastChunk: boolean; + }; + }; + +export class SendEIP712StructImplemCommand implements Command { + constructor(private readonly args: SendEIP712StructImplemCommandArgs) {} + + getApdu(): Apdu { + const apduBuilderArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x1c, + p1: + this.args.type != StructImplemType.FIELD || this.args.value.isLastChunk + ? 0x00 + : 0x01, + p2: this.args.type, + }; + switch (this.args.type) { + case StructImplemType.ROOT: + return new ApduBuilder(apduBuilderArgs) + .addAsciiStringToData(this.args.value) + .build(); + case StructImplemType.ARRAY: + return new ApduBuilder(apduBuilderArgs) + .add8BitUIntToData(this.args.value) + .build(); + case StructImplemType.FIELD: + return new ApduBuilder(apduBuilderArgs) + .addBufferToData(this.args.value.data) + .build(); + } + } + + parseResponse(response: ApduResponse): void { + const parser = new ApduParser(response); + + // TODO: handle the error correctly using a generic error handler + if (!CommandUtils.isSuccessResponse(response)) { + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + response.statusCode, + )}`, + ); + } + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SetExternalPluginCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SetExternalPluginCommand.test.ts new file mode 100644 index 000000000..72f208705 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SetExternalPluginCommand.test.ts @@ -0,0 +1,85 @@ +import { + ApduResponse, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +import { SetExternalPluginCommand } from "@internal/app-binder/command/SetExternalPluginCommand"; + +/** Test payload contains: + * Length of plugin name : 08 + * Plugin Name : Paraswap + * contract address: 0xdef171fe48cf0115b1d80b88dc8eab59176fee57 + * method selector: 0xa9059cbb + * **/ +const SET_EXTERNAL_PLUGIN_PAYLOAD = [ + 0x08, 0x50, 0x61, 0x72, 0x61, 0x73, 0x77, 0x61, 0x70, 0xde, 0xf1, 0x71, 0xfe, + 0x48, 0xcf, 0x01, 0x15, 0xb1, 0xd8, 0x0b, 0x88, 0xdc, 0x8e, 0xab, 0x59, 0x17, + 0x6f, 0xee, 0x57, 0xa9, 0x05, 0x9c, 0xbb, +]; +// Public signature key: https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#set-external-plugin +const SET_EXTERNAL_PLUGIN_SIGNATURE = [ + 0x04, 0x82, 0xbb, 0xf2, 0xf3, 0x4f, 0x36, 0x7b, 0x2e, 0x5b, 0xc2, 0x18, 0x47, + 0xb6, 0x56, 0x6f, 0x21, 0xf0, 0x97, 0x6b, 0x22, 0xd3, 0x38, 0x8a, 0x9a, 0x5e, + 0x44, 0x6a, 0xc6, 0x2d, 0x25, 0xcf, 0x72, 0x5b, 0x62, 0xa2, 0x55, 0x5b, 0x2d, + 0xd4, 0x64, 0xa4, 0xda, 0x0a, 0xb2, 0xf4, 0xd5, 0x06, 0x82, 0x05, 0x43, 0xaf, + 0x1d, 0x24, 0x24, 0x70, 0xb1, 0xb1, 0xa9, 0x69, 0xa2, 0x75, 0x78, 0xf3, 0x53, +]; +const SET_EXTERNAL_PLUGIN_APDU = [ + 0xe0, + 0x12, + 0x00, + 0x00, + SET_EXTERNAL_PLUGIN_PAYLOAD.length + SET_EXTERNAL_PLUGIN_SIGNATURE.length, + ...SET_EXTERNAL_PLUGIN_PAYLOAD, + ...SET_EXTERNAL_PLUGIN_SIGNATURE, +]; + +describe("Set External plugin", () => { + describe("getApdu", () => { + it("should retrieve correct apdu", () => { + // given + const command = new SetExternalPluginCommand({ + payload: Uint8Array.from(SET_EXTERNAL_PLUGIN_PAYLOAD), + signature: Uint8Array.from(SET_EXTERNAL_PLUGIN_SIGNATURE), + }); + // when + const apdu = command.getApdu(); + // then + expect(apdu.getRawApdu()).toStrictEqual( + Uint8Array.from(SET_EXTERNAL_PLUGIN_APDU), + ); + }); + }); + describe("parseResponse", () => { + it("should throw an error if status is invalid", () => { + // given + const command = new SetExternalPluginCommand({ + payload: Uint8Array.from([]), + signature: Uint8Array.from([]), + }); + // when + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x6a, 0x80]), + data: Uint8Array.from([]), + }); + // then + expect(() => command.parseResponse(apduResponse)).toThrowError( + InvalidStatusWordError, + ); + }); + it("should return void if status is success", () => { + // given + const command = new SetExternalPluginCommand({ + payload: Uint8Array.from([]), + signature: Uint8Array.from([]), + }); + // when + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }); + // then + expect(command.parseResponse(apduResponse)).toBe(void 0); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SetExternalPluginCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SetExternalPluginCommand.ts index faa2579b7..dd7bc3171 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/SetExternalPluginCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SetExternalPluginCommand.ts @@ -1,2 +1,62 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#set-external-plugin -export class SetExternalPluginCommand {} + +import { + Apdu, + ApduBuilder, + ApduBuilderArgs, + ApduParser, + ApduResponse, + Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +type SetExternalPluginCommandArgs = { + payload: Uint8Array; + signature: Uint8Array; +}; + +export class SetExternalPluginCommand + implements Command +{ + constructor(private readonly args: SetExternalPluginCommandArgs) {} + + getApdu(): Apdu { + const { payload, signature } = this.args; + const setExternalPluginBuilderArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x12, + p1: 0x00, + p2: 0x00, + }; + const builder = new ApduBuilder(setExternalPluginBuilderArgs); + builder.addBufferToData(payload); + builder.addBufferToData(signature); + + return builder.build(); + } + + parseResponse(apduResponse: ApduResponse): void { + if (CommandUtils.isSuccessResponse(apduResponse)) { + return; + } + + const parser = new ApduParser(apduResponse); + const statusCodeHex = parser.encodeToHexaString(apduResponse.statusCode); + + switch (statusCodeHex) { + case "6a80": + throw new InvalidStatusWordError("Invalid plugin name size"); + case "6984": + throw new InvalidStatusWordError("Plugin not installed on device"); + case "6d00": + throw new InvalidStatusWordError("Version of Eth app not supported"); + default: + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + apduResponse.statusCode, + )}`, + ); + } + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SetPluginCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SetPluginCommand.test.ts new file mode 100644 index 000000000..821980886 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SetPluginCommand.test.ts @@ -0,0 +1,72 @@ +import { + ApduResponse, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +import { SetPluginCommand, SetPluginCommandArgs } from "./SetPluginCommand"; + +const SET_PLUGIN_COMMAND_PAYLOAD = + "010106455243373231c5b07a55501014f36ec5d39d950a321439f6dd7642842e0e0000000000000001020147304502206d9f515916283e08fa6cdab205668c0739c558dcd6691a69ce74cd89fbc2cc6e022100c28c17b058e6d453570a58d69ff62042037dc61149af2f5161d5c36fdc5dc301"; + +const SET_PLUGIN_COMMAND_APDU = Uint8Array.from([ + 0xe0, 0x16, 0x00, 0x00, 0x73, 0x01, 0x01, 0x06, 0x45, 0x52, 0x43, 0x37, 0x32, + 0x31, 0xc5, 0xb0, 0x7a, 0x55, 0x50, 0x10, 0x14, 0xf3, 0x6e, 0xc5, 0xd3, 0x9d, + 0x95, 0x0a, 0x32, 0x14, 0x39, 0xf6, 0xdd, 0x76, 0x42, 0x84, 0x2e, 0x0e, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x01, 0x47, 0x30, 0x45, 0x02, + 0x20, 0x6d, 0x9f, 0x51, 0x59, 0x16, 0x28, 0x3e, 0x08, 0xfa, 0x6c, 0xda, 0xb2, + 0x05, 0x66, 0x8c, 0x07, 0x39, 0xc5, 0x58, 0xdc, 0xd6, 0x69, 0x1a, 0x69, 0xce, + 0x74, 0xcd, 0x89, 0xfb, 0xc2, 0xcc, 0x6e, 0x02, 0x21, 0x00, 0xc2, 0x8c, 0x17, + 0xb0, 0x58, 0xe6, 0xd4, 0x53, 0x57, 0x0a, 0x58, 0xd6, 0x9f, 0xf6, 0x20, 0x42, + 0x03, 0x7d, 0xc6, 0x11, 0x49, 0xaf, 0x2f, 0x51, 0x61, 0xd5, 0xc3, 0x6f, 0xdc, + 0x5d, 0xc3, 0x01, +]); + +describe("SetPluginCommand", () => { + describe("getApdu", () => { + it("returns the correct APDU", () => { + // GIVEN + const args: SetPluginCommandArgs = { + data: SET_PLUGIN_COMMAND_PAYLOAD, + }; + // WHEN + const command = new SetPluginCommand(args); + const apdu = command.getApdu(); + // THEN + expect(apdu.getRawApdu()).toStrictEqual(SET_PLUGIN_COMMAND_APDU); + }); + }); + describe("parseResponse", () => { + it("should throw an error if the response status code is invalid", () => { + // GIVEN + const invalidStatusCodes = [ + [0x6a, 0x80], + [0x69, 0x84], + [0x6d, 0x00], + ]; + for (const statusCode of invalidStatusCodes) { + const response: ApduResponse = { + data: Buffer.from([]), + statusCode: Buffer.from(statusCode), // Invalid status code + }; + // WHEN + const command = new SetPluginCommand({ data: "" }); + // THEN + expect(() => command.parseResponse(response)).toThrow( + InvalidStatusWordError, + ); + } + }); + + it("should not throw if the response status code is correct", () => { + // GIVEN + const response: ApduResponse = { + data: Buffer.from([]), + statusCode: Buffer.from([0x90, 0x00]), // Success status code + }; + // WHEN + const command = new SetPluginCommand({ data: "" }); + // THEN + expect(() => command.parseResponse(response)).not.toThrow(); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SetPluginCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SetPluginCommand.ts index bb22716aa..1385ae19a 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/SetPluginCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SetPluginCommand.ts @@ -1,2 +1,66 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#set-plugin -export class SetPluginCommand {} +import { + Apdu, + ApduBuilder, + type ApduBuilderArgs, + ApduParser, + ApduResponse, + type Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; + +export type SetPluginCommandArgs = { + /** + * The stringified hexa representation of the plugin signature. + */ + data: string; +}; + +export class SetPluginCommand implements Command { + constructor(private args: SetPluginCommandArgs) {} + + getApdu(): Apdu { + const apduBuilderArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x16, + p1: 0x00, + p2: 0x00, + }; + + return new ApduBuilder(apduBuilderArgs) + .addHexaStringToData(this.args.data) + .build(); + } + + parseResponse(response: ApduResponse): void { + const parser = new ApduParser(response); + + // TODO: handle the error correctly using a generic error handler. These error status codes come from the LL implementation, just for backup for now. + if (!CommandUtils.isSuccessResponse(response)) { + if (response.statusCode[0] === 0x6a && response.statusCode[1] === 0x80) { + throw new InvalidStatusWordError( + "The plugin name is too short or too long", + ); + } else if ( + response.statusCode[0] === 0x69 && + response.statusCode[1] === 0x84 + ) { + throw new InvalidStatusWordError( + "the requested plugin is not installed on the device", + ); + } else if ( + response.statusCode[0] === 0x6d && + response.statusCode[1] === 0x00 + ) { + throw new InvalidStatusWordError("ETH app is not up to date"); + } else { + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + response.statusCode, + )}`, + ); + } + } + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SignEIP712Command.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SignEIP712Command.test.ts new file mode 100644 index 000000000..5d0d772e7 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SignEIP712Command.test.ts @@ -0,0 +1,109 @@ +import { + type Command, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; +import { Just, Nothing } from "purify-ts"; + +import { + SignEIP712Command, + SignEIP712CommandResponse, +} from "./SignEIP712Command"; + +const SIGN_EIP712_APDU = Uint8Array.from([ + 0xe0, 0x0c, 0x00, 0x01, 0x15, 0x05, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, 0x00, + 0x3c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); + +const SIGN_EIP712_APDU_V0 = Uint8Array.from([ + 0xe0, 0x0c, 0x00, 0x00, 0x55, 0x05, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, 0x00, + 0x3c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, +]); + +const LNX_RESPONSE_DATA_GOOD = Uint8Array.from([ + 0x1c, 0x8a, 0x54, 0x05, 0x10, 0xe1, 0x3b, 0x0f, 0x2b, 0x11, 0xa4, 0x51, 0x27, + 0x57, 0x16, 0xd2, 0x9e, 0x08, 0xca, 0xad, 0x07, 0xe8, 0x9a, 0x1c, 0x84, 0x96, + 0x47, 0x82, 0xfb, 0x5e, 0x1a, 0xd7, 0x88, 0x64, 0xa0, 0xde, 0x23, 0x5b, 0x27, + 0x0f, 0xbe, 0x81, 0xe8, 0xe4, 0x06, 0x88, 0xf4, 0xa9, 0xf9, 0xad, 0x9d, 0x28, + 0x3d, 0x69, 0x05, 0x52, 0xc9, 0x33, 0x1d, 0x77, 0x73, 0xce, 0xaf, 0xa5, 0x13, +]); + +const LNX_RESPONSE_GOOD = { + statusCode: Uint8Array.from([0x90, 0x00]), + data: LNX_RESPONSE_DATA_GOOD, +}; + +const LNX_RESPONSE_LOCKED = { + statusCode: Uint8Array.from([0x55, 0x15]), + data: new Uint8Array(), +}; + +const LNX_RESPONSE_DATA_TOO_SHORT = Uint8Array.from([0x01, 0x02]); + +const LNX_RESPONSE_TOO_SHORT = { + statusCode: Uint8Array.from([0x90, 0x00]), + data: LNX_RESPONSE_DATA_TOO_SHORT, +}; + +describe("SignEIP712Command", () => { + let command: Command; + + beforeEach(() => { + command = new SignEIP712Command({ + derivationPath: "44'/60'/0'/0/0", + legacyArgs: Nothing, + }); + }); + + describe("getApdu", () => { + it("should provide the derivation path", () => { + const apdu = command.getApdu(); + expect(apdu.getRawApdu()).toStrictEqual(SIGN_EIP712_APDU); + }); + }); + + describe("parseResponse", () => { + it("should parse the response", () => { + const parsedResponse = command.parseResponse(LNX_RESPONSE_GOOD); + expect(parsedResponse).toStrictEqual({ + v: 0x1c, + r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788", + s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513", + }); + }); + + it("should throw an error if the response is not successful", () => { + expect(() => { + command.parseResponse(LNX_RESPONSE_LOCKED); + }).toThrow(InvalidStatusWordError); + }); + + it("should throw an error if the response is too short", () => { + expect(() => { + command.parseResponse(LNX_RESPONSE_TOO_SHORT); + }).toThrow(InvalidStatusWordError); + }); + }); +}); + +describe("SignEIP712Command V0", () => { + describe("getApdu", () => { + it("should provide the derivation path and hashes", () => { + const command = new SignEIP712Command({ + derivationPath: "44'/60'/0'/0/0", + legacyArgs: Just({ + domainHash: + "0x1111111111111111111111111111111111111111111111111111111111111111", + messageHash: + "0x2222222222222222222222222222222222222222222222222222222222222222", + }), + }); + const apdu = command.getApdu(); + expect(apdu.getRawApdu()).toStrictEqual(SIGN_EIP712_APDU_V0); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SignEIP712Command.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SignEIP712Command.ts index f18b603bb..a1e0d20ca 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/SignEIP712Command.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SignEIP712Command.ts @@ -1,2 +1,105 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#sign-eth-eip-712 -export class SignEIP712Command {} +import { + Apdu, + ApduBuilder, + type ApduBuilderArgs, + ApduParser, + ApduResponse, + type Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; +import { Maybe } from "purify-ts"; + +import { type Signature } from "@api/model/Signature"; +import { DerivationPathUtils } from "@internal/shared/utils/DerivationPathUtils"; + +const R_LENGTH = 32; +const S_LENGTH = 32; + +/** + * Legacy implementation parameters. It is now replaced with prior calls to the following commands: + * - SendEIP712StructDefinitionCommand + * - SendEIP712StructImplemCommand + * - SendEIP712FilteringCommand + */ +export type SignEIP712CommandV0Args = { + domainHash: string; + messageHash: string; +}; + +export type SignEIP712CommandArgs = { + readonly derivationPath: string; + readonly legacyArgs: Maybe; +}; + +export type SignEIP712CommandResponse = Signature; + +export class SignEIP712Command + implements Command +{ + constructor(private readonly args: SignEIP712CommandArgs) {} + + getApdu(): Apdu { + const { derivationPath, legacyArgs } = this.args; + + const signEIP712Args: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x0c, + p1: 0x00, + p2: legacyArgs.isJust() ? 0x00 : 0x01, + }; + const paths = DerivationPathUtils.splitPath(derivationPath); + const builder = new ApduBuilder(signEIP712Args); + builder.add8BitUIntToData(paths.length); + for (const path of paths) { + builder.add32BitUIntToData(path); + } + + legacyArgs.ifJust(({ domainHash, messageHash }) => { + builder.addHexaStringToData(domainHash); + builder.addHexaStringToData(messageHash); + }); + + return builder.build(); + } + + parseResponse(apduResponse: ApduResponse): SignEIP712CommandResponse { + const parser = new ApduParser(apduResponse); + + if (!CommandUtils.isSuccessResponse(apduResponse)) { + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + apduResponse.statusCode, + )}`, + ); + } + + const v = parser.extract8BitUInt(); + if (!v) { + throw new InvalidStatusWordError("V is missing"); + } + + const r = parser.encodeToHexaString( + parser.extractFieldByLength(R_LENGTH), + true, + ); + if (!r) { + throw new InvalidStatusWordError("R is missing"); + } + + const s = parser.encodeToHexaString( + parser.extractFieldByLength(S_LENGTH), + true, + ); + if (!s) { + throw new InvalidStatusWordError("S is missing"); + } + + return { + r, + s, + v, + }; + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SignPersonalMessageCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SignPersonalMessageCommand.test.ts new file mode 100644 index 000000000..28b6d9648 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SignPersonalMessageCommand.test.ts @@ -0,0 +1,244 @@ +import { + ApduResponse, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; +import { Just, Nothing } from "purify-ts"; + +import { SignPersonalMessageCommand } from "./SignPersonalMessageCommand"; + +const DERIVATION_PATH = "44'/60'/0'/0/0"; + +const SIGN_PERSONAL_EMPTY_MESSAGE_APDU = new Uint8Array([ + 0xe0, 0x08, 0x00, 0x00, 0x19, 0x05, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, 0x00, + 0x3c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, +]); + +const SIGN_PERSONAL_MESSAGE_INVALID_INDEX_APDU = new Uint8Array([ + 0xe0, 0x08, 0x80, 0x00, 0x00, +]); + +const SIGN_PERSONAL_SHORT_MESSAGE = "test"; +const SIGN_PERSONAL_SHORT_MESSAGE_APDU = new Uint8Array([ + 0xe0, 0x08, 0x00, 0x00, 0x1d, 0x05, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, 0x00, + 0x3c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x04, 0x74, 0x65, 0x73, 0x74, +]); + +const SIGN_PERSONAL_LONG_MESSAGE = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut in porta est, non vehicula enim. Etiam leo diam, accumsan ac pretium et, tincidunt in nunc. Quisque faucibus fermentum maximus. Donec non nisi ut erat auctor congue a vehicula neque. Maecenas volutpat lectus vel bibendum mattis. Aenean feugiat nulla diam, vitae interdum lacus ornare ac. Cras posuere, elit at convallis pretium, risus tortor volutpat sapien, eu mollis sapien dolor id sapien. Ut efficitur, ipsum vitae feugiat congue, ex nibh tristique nibh."; + +const SIGN_PERSONAL_LONG_MESSAGE_FIRST_APDU = new Uint8Array([ + 0xe0, 0x08, 0x00, 0x00, 0x96, 0x05, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, 0x00, + 0x3c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x02, 0x06, 0x4c, 0x6f, 0x72, 0x65, 0x6d, 0x20, 0x69, 0x70, 0x73, + 0x75, 0x6d, 0x20, 0x64, 0x6f, 0x6c, 0x6f, 0x72, 0x20, 0x73, 0x69, 0x74, 0x20, + 0x61, 0x6d, 0x65, 0x74, 0x2c, 0x20, 0x63, 0x6f, 0x6e, 0x73, 0x65, 0x63, 0x74, + 0x65, 0x74, 0x75, 0x72, 0x20, 0x61, 0x64, 0x69, 0x70, 0x69, 0x73, 0x63, 0x69, + 0x6e, 0x67, 0x20, 0x65, 0x6c, 0x69, 0x74, 0x2e, 0x20, 0x55, 0x74, 0x20, 0x69, + 0x6e, 0x20, 0x70, 0x6f, 0x72, 0x74, 0x61, 0x20, 0x65, 0x73, 0x74, 0x2c, 0x20, + 0x6e, 0x6f, 0x6e, 0x20, 0x76, 0x65, 0x68, 0x69, 0x63, 0x75, 0x6c, 0x61, 0x20, + 0x65, 0x6e, 0x69, 0x6d, 0x2e, 0x20, 0x45, 0x74, 0x69, 0x61, 0x6d, 0x20, 0x6c, + 0x65, 0x6f, 0x20, 0x64, 0x69, 0x61, 0x6d, 0x2c, 0x20, 0x61, 0x63, 0x63, 0x75, + 0x6d, 0x73, 0x61, 0x6e, 0x20, 0x61, 0x63, 0x20, 0x70, 0x72, 0x65, 0x74, +]); + +const SIGN_PERSONAL_LONG_MESSAGE_SECOND_APDU = new Uint8Array([ + 0xe0, 0x08, 0x80, 0x00, 0x96, 0x69, 0x75, 0x6d, 0x20, 0x65, 0x74, 0x2c, 0x20, + 0x74, 0x69, 0x6e, 0x63, 0x69, 0x64, 0x75, 0x6e, 0x74, 0x20, 0x69, 0x6e, 0x20, + 0x6e, 0x75, 0x6e, 0x63, 0x2e, 0x20, 0x51, 0x75, 0x69, 0x73, 0x71, 0x75, 0x65, + 0x20, 0x66, 0x61, 0x75, 0x63, 0x69, 0x62, 0x75, 0x73, 0x20, 0x66, 0x65, 0x72, + 0x6d, 0x65, 0x6e, 0x74, 0x75, 0x6d, 0x20, 0x6d, 0x61, 0x78, 0x69, 0x6d, 0x75, + 0x73, 0x2e, 0x20, 0x44, 0x6f, 0x6e, 0x65, 0x63, 0x20, 0x6e, 0x6f, 0x6e, 0x20, + 0x6e, 0x69, 0x73, 0x69, 0x20, 0x75, 0x74, 0x20, 0x65, 0x72, 0x61, 0x74, 0x20, + 0x61, 0x75, 0x63, 0x74, 0x6f, 0x72, 0x20, 0x63, 0x6f, 0x6e, 0x67, 0x75, 0x65, + 0x20, 0x61, 0x20, 0x76, 0x65, 0x68, 0x69, 0x63, 0x75, 0x6c, 0x61, 0x20, 0x6e, + 0x65, 0x71, 0x75, 0x65, 0x2e, 0x20, 0x4d, 0x61, 0x65, 0x63, 0x65, 0x6e, 0x61, + 0x73, 0x20, 0x76, 0x6f, 0x6c, 0x75, 0x74, 0x70, 0x61, 0x74, 0x20, 0x6c, 0x65, + 0x63, 0x74, 0x75, 0x73, 0x20, 0x76, 0x65, 0x6c, 0x20, 0x62, 0x69, 0x62, +]); +const SIGN_PERSONAL_LONG_MESSAGE_THIRD_APDU = new Uint8Array([ + 0xe0, 0x08, 0x80, 0x00, 0x96, 0x65, 0x6e, 0x64, 0x75, 0x6d, 0x20, 0x6d, 0x61, + 0x74, 0x74, 0x69, 0x73, 0x2e, 0x20, 0x41, 0x65, 0x6e, 0x65, 0x61, 0x6e, 0x20, + 0x66, 0x65, 0x75, 0x67, 0x69, 0x61, 0x74, 0x20, 0x6e, 0x75, 0x6c, 0x6c, 0x61, + 0x20, 0x64, 0x69, 0x61, 0x6d, 0x2c, 0x20, 0x76, 0x69, 0x74, 0x61, 0x65, 0x20, + 0x69, 0x6e, 0x74, 0x65, 0x72, 0x64, 0x75, 0x6d, 0x20, 0x6c, 0x61, 0x63, 0x75, + 0x73, 0x20, 0x6f, 0x72, 0x6e, 0x61, 0x72, 0x65, 0x20, 0x61, 0x63, 0x2e, 0x20, + 0x43, 0x72, 0x61, 0x73, 0x20, 0x70, 0x6f, 0x73, 0x75, 0x65, 0x72, 0x65, 0x2c, + 0x20, 0x65, 0x6c, 0x69, 0x74, 0x20, 0x61, 0x74, 0x20, 0x63, 0x6f, 0x6e, 0x76, + 0x61, 0x6c, 0x6c, 0x69, 0x73, 0x20, 0x70, 0x72, 0x65, 0x74, 0x69, 0x75, 0x6d, + 0x2c, 0x20, 0x72, 0x69, 0x73, 0x75, 0x73, 0x20, 0x74, 0x6f, 0x72, 0x74, 0x6f, + 0x72, 0x20, 0x76, 0x6f, 0x6c, 0x75, 0x74, 0x70, 0x61, 0x74, 0x20, 0x73, 0x61, + 0x70, 0x69, 0x65, 0x6e, 0x2c, 0x20, 0x65, 0x75, 0x20, 0x6d, 0x6f, 0x6c, +]); + +const SIGN_PERSONAL_LONG_MESSAGE_FOURTH_APDU = new Uint8Array([ + 0xe0, 0x08, 0x80, 0x00, 0x5d, 0x6c, 0x69, 0x73, 0x20, 0x73, 0x61, 0x70, 0x69, + 0x65, 0x6e, 0x20, 0x64, 0x6f, 0x6c, 0x6f, 0x72, 0x20, 0x69, 0x64, 0x20, 0x73, + 0x61, 0x70, 0x69, 0x65, 0x6e, 0x2e, 0x20, 0x55, 0x74, 0x20, 0x65, 0x66, 0x66, + 0x69, 0x63, 0x69, 0x74, 0x75, 0x72, 0x2c, 0x20, 0x69, 0x70, 0x73, 0x75, 0x6d, + 0x20, 0x76, 0x69, 0x74, 0x61, 0x65, 0x20, 0x66, 0x65, 0x75, 0x67, 0x69, 0x61, + 0x74, 0x20, 0x63, 0x6f, 0x6e, 0x67, 0x75, 0x65, 0x2c, 0x20, 0x65, 0x78, 0x20, + 0x6e, 0x69, 0x62, 0x68, 0x20, 0x74, 0x72, 0x69, 0x73, 0x74, 0x69, 0x71, 0x75, + 0x65, 0x20, 0x6e, 0x69, 0x62, 0x68, 0x2e, +]); + +const SIGN_PERSONAL_LONG_MESSAGE_APDUS = [ + SIGN_PERSONAL_LONG_MESSAGE_FIRST_APDU, + SIGN_PERSONAL_LONG_MESSAGE_SECOND_APDU, + SIGN_PERSONAL_LONG_MESSAGE_THIRD_APDU, + SIGN_PERSONAL_LONG_MESSAGE_FOURTH_APDU, +]; + +const SIGN_PERSONAL_MESSAGE_SHORT_SUCCESS_RESPONSE = new Uint8Array([ + 0x1b, 0x97, 0xa4, 0xca, 0x8f, 0x69, 0x46, 0x33, 0x59, 0x26, 0x01, 0xf5, 0xa2, + 0x3e, 0x0b, 0xcc, 0x55, 0x3c, 0x9d, 0x0a, 0x90, 0xd3, 0xa3, 0x42, 0x2d, 0x57, + 0x55, 0x08, 0xa9, 0x28, 0x98, 0xb9, 0x6e, 0x69, 0x50, 0xd0, 0x2e, 0x74, 0xe9, + 0xc1, 0x02, 0xc1, 0x64, 0xa2, 0x25, 0x53, 0x30, 0x82, 0xca, 0xbd, 0xd8, 0x90, + 0xef, 0xc4, 0x63, 0xf6, 0x7f, 0x60, 0xce, 0xfe, 0x8c, 0x3f, 0x87, 0xcf, 0xce, +]); + +const SIGN_PERSONAL_LONG_MESSAGE_SUCCESS_RESPONSE = new Uint8Array([ + 0x1b, 0x19, 0x10, 0x0e, 0x53, 0x38, 0xbc, 0x6c, 0x77, 0x20, 0xbb, 0x47, 0xcf, + 0x39, 0x23, 0x7b, 0x4f, 0x27, 0x31, 0x6c, 0xb2, 0xe4, 0xe2, 0xff, 0x00, 0x46, + 0x18, 0xb7, 0x63, 0xc8, 0x6c, 0x8a, 0x06, 0x0f, 0xf0, 0x1a, 0x85, 0x57, 0x18, + 0xd7, 0x97, 0x5c, 0x1c, 0x54, 0xab, 0xcf, 0x7d, 0x32, 0xff, 0x96, 0x30, 0x7c, + 0x0b, 0xda, 0x8d, 0x69, 0x5d, 0x14, 0x29, 0x0d, 0x4b, 0xc5, 0x4d, 0x27, 0x8b, +]); + +describe("SignPersonalMessageCommand", (): void => { + describe("getApdu", () => { + it("should return correct apdu for an empty message", () => { + // given + const command = new SignPersonalMessageCommand({ + derivationPath: DERIVATION_PATH, + message: "", + index: 0, + }); + // when + const apdu = command.getApdu(); + // then + expect(apdu.getRawApdu()).toStrictEqual(SIGN_PERSONAL_EMPTY_MESSAGE_APDU); + }); + it("should return correct apdu for an invalid index", () => { + // given + const command = new SignPersonalMessageCommand({ + derivationPath: DERIVATION_PATH, + message: SIGN_PERSONAL_SHORT_MESSAGE, + index: 42, + }); + // when + const apdu = command.getApdu(); + // then + expect(apdu.getRawApdu()).toStrictEqual( + SIGN_PERSONAL_MESSAGE_INVALID_INDEX_APDU, + ); + }); + it("should return the signPersonalMessage apdu for a short message", () => { + // given + const command = new SignPersonalMessageCommand({ + derivationPath: DERIVATION_PATH, + message: SIGN_PERSONAL_SHORT_MESSAGE, + index: 0, + }); + // when + const apdu = command.getApdu(); + // then + expect(apdu.getRawApdu()).toStrictEqual(SIGN_PERSONAL_SHORT_MESSAGE_APDU); + }); + it.each(SIGN_PERSONAL_LONG_MESSAGE_APDUS)( + "should return correct apdu for a long message at index %#", + (expectedApdu) => { + // given + const command = new SignPersonalMessageCommand({ + derivationPath: DERIVATION_PATH, + message: SIGN_PERSONAL_LONG_MESSAGE, + index: SIGN_PERSONAL_LONG_MESSAGE_APDUS.indexOf(expectedApdu), + }); + // when + const apdu = command.getApdu(); + // then + expect(apdu.getRawApdu()).toStrictEqual(expectedApdu); + }, + ); + }); + + describe("parseResponse", () => { + it("should return correct response after signing success for a short message", () => { + // given + const command = new SignPersonalMessageCommand({ + message: SIGN_PERSONAL_SHORT_MESSAGE, + derivationPath: DERIVATION_PATH, + index: 0, + }); + const apduResponse = new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: SIGN_PERSONAL_MESSAGE_SHORT_SUCCESS_RESPONSE, + }); + // when + const response = command.parseResponse(apduResponse); + // then + expect(response).toStrictEqual( + Just({ + r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", + s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", + v: 27, + }), + ); + }); + it("should throw an error if user refused on device", () => { + const command = new SignPersonalMessageCommand({ + message: SIGN_PERSONAL_SHORT_MESSAGE, + derivationPath: DERIVATION_PATH, + index: 0, + }); + const apduResponse = new ApduResponse({ + statusCode: new Uint8Array([0x69, 0x85]), + data: new Uint8Array([]), + }); + // when + const response = () => command.parseResponse(apduResponse); + // then + expect(response).toThrow(InvalidStatusWordError); + }); + it("should return nothing if not last index of a long message", () => { + // given + const command = new SignPersonalMessageCommand({ + derivationPath: DERIVATION_PATH, + message: SIGN_PERSONAL_LONG_MESSAGE, + index: 0, + }); + // when + const response = command.parseResponse( + new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: new Uint8Array([]), + }), + ); + // then + expect(response).toStrictEqual(Nothing); + }); + it("should return correct response of a long message", () => { + // given + const command = new SignPersonalMessageCommand({ + message: SIGN_PERSONAL_LONG_MESSAGE, + derivationPath: DERIVATION_PATH, + index: 0, + }); + const apduResponse = new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: SIGN_PERSONAL_LONG_MESSAGE_SUCCESS_RESPONSE, + }); + // when + const response = command.parseResponse(apduResponse); + // then + expect(response).toStrictEqual( + Just({ + r: "0x19100e5338bc6c7720bb47cf39237b4f27316cb2e4e2ff004618b763c86c8a06", + s: "0x0ff01a855718d7975c1c54abcf7d32ff96307c0bda8d695d14290d4bc54d278b", + v: 27, + }), + ); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SignPersonalMessageCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SignPersonalMessageCommand.ts index 4bbb9bb19..c3d29f06a 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/SignPersonalMessageCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SignPersonalMessageCommand.ts @@ -1,2 +1,131 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#sign-eth-personal-message -export class SignPersonalMessageCommand {} +import { + Apdu, + ApduBuilder, + ApduBuilderArgs, + ApduParser, + ApduResponse, + Command, + CommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; +import { Just, Maybe, Nothing } from "purify-ts"; + +import { Signature } from "@api/model/Signature"; +import { DerivationPathUtils } from "@internal/shared/utils/DerivationPathUtils"; + +const MAX_CHUNK_SIZE = 150; +const PATH_SIZE = 4; +const MESSAGE_LENGTH_SIZE = 4; +const DERIVATIONS_COUNT_SIZE = 1; +const R_LENGTH = 32; +const S_LENGTH = 32; + +export type SignPersonalMessageCommandArgs = { + /** + * The derivation path to use to sign the transaction. + */ + readonly derivationPath: string; + /** + * The complete serialized transaction data. + */ + readonly message: string; + /** + * The index of the chunk to sign. + */ + readonly index: number; +}; + +export type SignPersonalMessageCommandResponse = Maybe; + +export class SignPersonalMessageCommand + implements + Command +{ + readonly args: SignPersonalMessageCommandArgs; + + constructor(args: SignPersonalMessageCommandArgs) { + this.args = args; + } + + getApdu(): Apdu { + const { derivationPath, message, index } = this.args; + const signPersonalMessageArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x08, + p1: index === 0 ? 0x00 : 0x80, + p2: 0x00, + }; + const paths = DerivationPathUtils.splitPath(derivationPath); + const builder = new ApduBuilder(signPersonalMessageArgs); + const messageFirstChunkIndex = + MAX_CHUNK_SIZE - + paths.length * PATH_SIZE - + DERIVATIONS_COUNT_SIZE - + MESSAGE_LENGTH_SIZE; + + if (index === 0) { + // add derivation paths count to the first packet + builder.add8BitUIntToData(paths.length); + // add every derivation path + paths.forEach((path) => { + builder.add32BitUIntToData(path); + }); + // add message length + builder.add32BitUIntToData(message.length); + // add 150 bytes of data minus the count of derivation, the path size and the message length + builder.addAsciiStringToData(message.slice(0, messageFirstChunkIndex)); + } else { + // add 150 bytes of data starting from the second packet + builder.addAsciiStringToData( + message.slice( + messageFirstChunkIndex + (index - 1) * MAX_CHUNK_SIZE, + messageFirstChunkIndex + index * MAX_CHUNK_SIZE, + ), + ); + } + return builder.build(); + } + + parseResponse( + apduResponse: ApduResponse, + ): SignPersonalMessageCommandResponse { + const parser = new ApduParser(apduResponse); + + if (!CommandUtils.isSuccessResponse(apduResponse)) { + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + apduResponse.statusCode, + )}`, + ); + } + + // The data is returned only for the last chunk + const v = parser.extract8BitUInt(); + if (!v) { + return Nothing; + } + + const r = parser.encodeToHexaString( + parser.extractFieldByLength(R_LENGTH), + true, + ); + if (!r) { + throw new InvalidStatusWordError("R is missing"); + } + + const s = parser.encodeToHexaString( + parser.extractFieldByLength(S_LENGTH), + true, + ); + if (!s) { + throw new InvalidStatusWordError("S is missing"); + } + + return Just({ + r, + s, + v, + }); + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SignTransactionCommand.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SignTransactionCommand.test.ts new file mode 100644 index 000000000..da5480a45 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SignTransactionCommand.test.ts @@ -0,0 +1,468 @@ +import { + ApduResponse, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; +import { Just, Nothing } from "purify-ts"; + +import { SignTransactionCommand } from "./SignTransactionCommand"; + +const SIGN_TRANSACTION_APDU_WITHOUT_DATA_FIRST = new Uint8Array([ + 0xe0, 0x04, 0x00, 0x00, 0x15, 0x05, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, 0x00, + 0x3c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]); + +const SIGN_TRANSACTION_APDU_WITHOUT_DATA_SECOND = new Uint8Array([ + 0xe0, 0x04, 0x80, 0x00, 0x00, +]); + +// tx eth 0x8ee3747112cd6cc9c045d6677e65e1c7b39b26bd612b745b8579ddede462f1c4 +const DATA_1_CHUNK = new Uint8Array([ + 0xe9, 0x02, 0x85, 0x01, 0x9e, 0x3c, 0x71, 0xc5, 0x82, 0x52, 0x08, 0x94, 0xd8, + 0xda, 0x6b, 0xf2, 0x69, 0x64, 0xaf, 0x9d, 0x7e, 0xed, 0x9e, 0x03, 0xe5, 0x34, + 0x15, 0xd3, 0x7a, 0xa9, 0x60, 0x45, 0x85, 0x17, 0x48, 0x76, 0xe8, 0x00, 0x80, + 0x01, 0x80, 0x80, +]); + +const SIGN_TRANSACTION_APDU_WITH_1_CHUNK = new Uint8Array([ + ...new Uint8Array([ + 0xe0, 0x04, 0x00, 0x00, 0x3f, 0x05, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, + 0x00, 0x3c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]), + ...DATA_1_CHUNK, +]); + +// tx eth 0xe7950ad373ebad3b63bf1c35c1bf0862c95cd0d0756f152fa7044c547402904b +const DATA_MULTIPLE_CHUNKS = new Uint8Array([ + 0xf9, 0x08, 0xaf, 0x26, 0x85, 0x01, 0xb2, 0x3d, 0x94, 0x83, 0x83, 0x05, 0xc1, + 0xfc, 0x94, 0xde, 0xf1, 0xc0, 0xde, 0xd9, 0xbe, 0xc7, 0xf1, 0xa1, 0x67, 0x08, + 0x19, 0x83, 0x32, 0x40, 0xf0, 0x27, 0xb2, 0x5e, 0xff, 0x80, 0xb9, 0x08, 0x88, + 0x41, 0x55, 0x65, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x1f, 0x98, 0x40, 0xa8, 0x5d, 0x5a, 0xf5, 0xbf, 0x1d, 0x17, + 0x62, 0xf9, 0x25, 0xbd, 0xad, 0xdc, 0x42, 0x01, 0xf9, 0x84, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa0, 0xb8, 0x69, 0x91, + 0xc6, 0x21, 0x8b, 0x36, 0xc1, 0xd1, 0x9d, 0x4a, 0x2e, 0x9e, 0xb0, 0xce, 0x36, + 0x06, 0xeb, 0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x61, 0xe9, 0x33, 0x59, 0x53, 0x95, 0x6c, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x33, + 0xef, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x40, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x05, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x40, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x03, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x1f, 0x98, 0x40, 0xa8, 0x5d, 0x5a, 0xf5, 0xbf, 0x1d, 0x17, 0x62, + 0xf9, 0x25, 0xbd, 0xad, 0xdc, 0x42, 0x01, 0xf9, 0x84, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa0, 0xb8, 0x69, 0x91, 0xc6, + 0x21, 0x8b, 0x36, 0xc1, 0xd1, 0x9d, 0x4a, 0x2e, 0x9e, 0xb0, 0xce, 0x36, 0x06, + 0xeb, 0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xe0, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x61, 0xe9, + 0x33, 0x59, 0x53, 0x95, 0x6c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x03, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x55, 0x6e, 0x69, 0x73, 0x77, 0x61, + 0x70, 0x56, 0x33, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x61, 0xe9, 0x33, 0x59, 0x53, + 0x95, 0x6c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x3d, 0x0d, 0xae, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe5, 0x92, 0x42, 0x7a, 0x0a, 0xec, + 0xe9, 0x2d, 0xe3, 0xed, 0xee, 0x1f, 0x18, 0xe0, 0x15, 0x7c, 0x05, 0x86, 0x15, + 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x2b, 0x1f, 0x98, 0x40, 0xa8, 0x5d, 0x5a, 0xf5, + 0xbf, 0x1d, 0x17, 0x62, 0xf9, 0x25, 0xbd, 0xad, 0xdc, 0x42, 0x01, 0xf9, 0x84, + 0x00, 0x0b, 0xb8, 0xa0, 0xb8, 0x69, 0x91, 0xc6, 0x21, 0x8b, 0x36, 0xc1, 0xd1, + 0x9d, 0x4a, 0x2e, 0x9e, 0xb0, 0xce, 0x36, 0x06, 0xeb, 0x48, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xa0, 0xb8, 0x69, 0x91, 0xc6, 0x21, 0x8b, 0x36, 0xc1, 0xd1, 0x9d, 0x4a, 0x2e, + 0x9e, 0xb0, 0xce, 0x36, 0x06, 0xeb, 0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x4a, 0x60, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x38, + 0x2f, 0xfc, 0xe2, 0x28, 0x72, 0x52, 0xf9, 0x30, 0xe1, 0xc8, 0xdc, 0x93, 0x28, + 0xda, 0xc5, 0xbf, 0x28, 0x2b, 0xa1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1b, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa0, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa0, 0xb8, 0x69, 0x91, + 0xc6, 0x21, 0x8b, 0x36, 0xc1, 0xd1, 0x9d, 0x4a, 0x2e, 0x9e, 0xb0, 0xce, 0x36, + 0x06, 0xeb, 0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xd4, 0x3c, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xad, 0x01, 0xc2, 0x0d, 0x58, + 0x86, 0x13, 0x7e, 0x05, 0x67, 0x75, 0xaf, 0x56, 0x91, 0x5d, 0xe8, 0x24, 0xc8, + 0xfc, 0xe5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x1f, 0x98, 0x40, 0xa8, 0x5d, 0x5a, 0xf5, 0xbf, 0x1d, + 0x17, 0x62, 0xf9, 0x25, 0xbd, 0xad, 0xdc, 0x42, 0x01, 0xf9, 0x84, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xee, 0xee, 0xee, + 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, + 0xee, 0xee, 0xee, 0xee, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x86, 0x95, 0x84, + 0xcd, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x38, 0x2f, 0xfc, 0xe2, 0x28, 0x72, 0x52, 0xf9, 0x30, 0xe1, 0xc8, 0xdc, 0x93, + 0x28, 0xda, 0xc5, 0xbf, 0x28, 0x2b, 0xa1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xf9, 0x14, 0x8f, 0x87, 0x7d, 0xa8, 0x9b, 0xe8, 0x0b, 0xd4, 0x62, 0x23, + 0x01, 0x80, 0x80, +]); + +const SIGN_TRANSACTION_APDU_WITH_MULTIPLE_CHUNKS_FIRST = new Uint8Array([ + ...new Uint8Array([ + 0xe0, 0x04, 0x00, 0x00, 0x96, 0x05, 0x80, 0x00, 0x00, 0x2c, 0x80, 0x00, + 0x00, 0x3c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]), + // 0 to 129: 130 bytes -> 150 total - 4 * 5 (paths) + ...DATA_MULTIPLE_CHUNKS.slice(0, 129), +]); + +const SIGN_TRANSACTION_APDU_WITH_MULTIPLE_CHUNKS_SECOND = new Uint8Array([ + ...new Uint8Array([0xe0, 0x04, 0x80, 0x00, 0x96]), + ...DATA_MULTIPLE_CHUNKS.slice(129, 279), +]); + +const SIGN_TRANSACTION_APDU_WITH_MULTIPLE_CHUNKS_THIRD = new Uint8Array([ + ...new Uint8Array([0xe0, 0x04, 0x80, 0x00, 0x96]), + ...DATA_MULTIPLE_CHUNKS.slice(279, 279 + 150), +]); + +const SIGN_TRANSACTION_APDU_WITH_MULTIPLE_CHUNKS_LAST = new Uint8Array([ + ...new Uint8Array([0xe0, 0x04, 0x80, 0x00, 0x93]), + ...DATA_MULTIPLE_CHUNKS.slice(2079, 2079 + 147), +]); + +const LNX_RESPONSE_GOOD = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: new Uint8Array([]), +}); + +const LNX_RESPONSE_DATA = new Uint8Array([ + 0x26, 0x8d, 0x27, 0x44, 0x47, 0x11, 0xbb, 0xed, 0x44, 0x2b, 0x9b, 0xfc, 0x77, + 0x05, 0xc0, 0x73, 0x16, 0xb7, 0xe4, 0x11, 0x50, 0xc5, 0x33, 0x12, 0x72, 0xe4, + 0xd2, 0x09, 0xd4, 0x22, 0xf9, 0xfa, 0x39, 0x00, 0xcc, 0x3f, 0x0c, 0x19, 0x38, + 0xc0, 0xf1, 0xff, 0xc6, 0x2d, 0xf0, 0x37, 0x22, 0x5a, 0x13, 0x36, 0xfb, 0xa1, + 0xf9, 0xfe, 0xfa, 0x11, 0xf5, 0xaf, 0xc5, 0xbc, 0xb9, 0x7e, 0xb1, 0xb3, 0xd1, + 0x90, 0x00, +]); + +const LNX_RESPONSE_DATA_GOOD = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: LNX_RESPONSE_DATA, +}); + +describe("SignTransactionCommand", () => { + const defaultArgs = { + derivationPath: "44'/60'/0'/0/0", + data: new Uint8Array(), + index: 0, + }; + + describe("getApdu", () => { + it("should return the SignTransaction APDU without data when isFirst is true", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + SIGN_TRANSACTION_APDU_WITHOUT_DATA_FIRST, + ); + }); + + it("should return the SignTransaction APDU with data when isFirst is false", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + index: 1, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + SIGN_TRANSACTION_APDU_WITHOUT_DATA_SECOND, + ); + }); + + it("should return the SignTransaction APDU when data is 1 chunk", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + data: DATA_1_CHUNK, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + SIGN_TRANSACTION_APDU_WITH_1_CHUNK, + ); + }); + + it("should return the first SignTransaction APDU when data is multiple chunks", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + data: DATA_MULTIPLE_CHUNKS, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + SIGN_TRANSACTION_APDU_WITH_MULTIPLE_CHUNKS_FIRST, + ); + }); + + it("should return the second SignTransaction APDU when data is multiple chunks", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + data: DATA_MULTIPLE_CHUNKS, + index: 1, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + SIGN_TRANSACTION_APDU_WITH_MULTIPLE_CHUNKS_SECOND, + ); + }); + + it("should return the third SignTransaction APDU when data is multiple chunks", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + data: DATA_MULTIPLE_CHUNKS, + index: 2, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + SIGN_TRANSACTION_APDU_WITH_MULTIPLE_CHUNKS_THIRD, + ); + }); + + it("should return the last SignTransaction APDU when data is multiple chunks", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + data: DATA_MULTIPLE_CHUNKS, + index: 14, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.getRawApdu()).toStrictEqual( + SIGN_TRANSACTION_APDU_WITH_MULTIPLE_CHUNKS_LAST, + ); + }); + }); + + describe("parseResponse", () => { + it("should return Nothing when the response data is empty", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + }); + + // WHEN + const response = command.parseResponse(LNX_RESPONSE_GOOD); + + // THEN + expect(response).toStrictEqual(Nothing); + }); + + it("should return Just the response data when the response data is not empty", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + }); + + // WHEN + const response = command.parseResponse(LNX_RESPONSE_DATA_GOOD); + + // THEN + expect(response).toStrictEqual( + Just({ + r: "0x8d27444711bbed442b9bfc7705c07316b7e41150c5331272e4d209d422f9fa39", + s: "0x00cc3f0c1938c0f1ffc62df037225a1336fba1f9fefa11f5afc5bcb97eb1b3d1", + v: 38, + }), + ); + }); + + it("should throw an error when the response status code is not 0x9000", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + }); + + // WHEN + const response = () => + command.parseResponse( + new ApduResponse({ + statusCode: Uint8Array.from([0x51, 0x55]), + data: new Uint8Array(), + }), + ); + + // THEN + expect(response).toThrow(InvalidStatusWordError); + }); + + it("should throw an error when the response data r is not valid", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + }); + + // WHEN + const response = () => + command.parseResponse( + new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: LNX_RESPONSE_DATA.slice(0, 1), + }), + ); + + // THEN + expect(response).toThrow(InvalidStatusWordError); + }); + + it("should throw an error when the response data s is not valid", () => { + // GIVEN + const command = new SignTransactionCommand({ + ...defaultArgs, + }); + + // WHEN + const response = () => + command.parseResponse( + new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: LNX_RESPONSE_DATA.slice(0, 33), + }), + ); + + // THEN + expect(response).toThrow(InvalidStatusWordError); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/command/SignTransactionCommand.ts b/packages/signer/keyring-eth/src/internal/app-binder/command/SignTransactionCommand.ts index 041be6c43..c7b139758 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/command/SignTransactionCommand.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/command/SignTransactionCommand.ts @@ -1,2 +1,130 @@ // https://github.com/LedgerHQ/app-ethereum/blob/develop/doc/ethapp.adoc#sign-eth-transaction -export class SignTransactionCommand {} +import { + Apdu, + ApduBuilder, + ApduBuilderArgs, + ApduParser, + ApduResponse, + type Command, + CommandUtils, + HexaString, + InvalidStatusWordError, +} from "@ledgerhq/device-sdk-core"; +import { Just, Maybe, Nothing } from "purify-ts"; + +import { DerivationPathUtils } from "@internal/shared/utils/DerivationPathUtils"; + +const MAX_CHUNK_SIZE = 150; +const PATH_SIZE = 4; +const R_LENGTH = 32; +const S_LENGTH = 32; + +export type SignTransactionCommandResponse = Maybe<{ + v: number; + r: HexaString; + s: HexaString; +}>; + +export type SignTransactionCommandArgs = { + /** + * The derivation path to use to sign the transaction. + */ + derivationPath: string; + + /** + * The complete serialized transaction data. + */ + data: Uint8Array; + + /** + * The index of the chunk to sign. + */ + index: number; +}; + +export class SignTransactionCommand + implements + Command +{ + args: SignTransactionCommandArgs; + + constructor(args: SignTransactionCommandArgs) { + this.args = args; + } + + getApdu(): Apdu { + const { data, derivationPath, index } = this.args; + + const signEthTransactionArgs: ApduBuilderArgs = { + cla: 0xe0, + ins: 0x04, + p1: index === 0 ? 0x00 : 0x80, + p2: 0x00, + }; + const builder = new ApduBuilder(signEthTransactionArgs); + const path = DerivationPathUtils.splitPath(derivationPath); + const dataFirstChunkIndex = MAX_CHUNK_SIZE - path.length * PATH_SIZE - 1; + + if (index === 0) { + // add derivation path to the first packet + builder.add8BitUIntToData(path.length); + path.forEach((element) => { + builder.add32BitUIntToData(element); + }); + + // add 150 bytes of data minus the path length and the path + builder.addBufferToData(data.slice(0, dataFirstChunkIndex)); + } else { + // add 150 bytes of data starting from the second packet + builder.addBufferToData( + data.slice( + dataFirstChunkIndex + (index - 1) * MAX_CHUNK_SIZE, + dataFirstChunkIndex + index * MAX_CHUNK_SIZE, + ), + ); + } + + return builder.build(); + } + + parseResponse(response: ApduResponse): SignTransactionCommandResponse { + const parser = new ApduParser(response); + + // TODO: handle the error correctly using a generic error handler + if (!CommandUtils.isSuccessResponse(response)) { + throw new InvalidStatusWordError( + `Unexpected status word: ${parser.encodeToHexaString( + response.statusCode, + )}`, + ); + } + + // The data is returned only for the last chunk + const v = parser.extract8BitUInt(); + if (!v) { + return Nothing; + } + + const r = parser.encodeToHexaString( + parser.extractFieldByLength(R_LENGTH), + true, + ); + if (!r) { + throw new InvalidStatusWordError("R is missing"); + } + + const s = parser.encodeToHexaString( + parser.extractFieldByLength(S_LENGTH), + true, + ); + if (!s) { + throw new InvalidStatusWordError("S is missing"); + } + + return Just({ + v, + r, + s, + }); + } +} diff --git a/packages/signer/keyring-eth/src/internal/shared/utils/DerivationPathUtils.test.ts b/packages/signer/keyring-eth/src/internal/shared/utils/DerivationPathUtils.test.ts new file mode 100644 index 000000000..2e55de37d --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/shared/utils/DerivationPathUtils.test.ts @@ -0,0 +1,76 @@ +import { DerivationPathUtils } from "./DerivationPathUtils"; + +describe("DerivationPathUtils", () => { + it("padding should be 0x80000000", () => { + // GIVEN + const padding = 0x80000000; + + // WHEN + const result = DerivationPathUtils.padding; + + // THEN + expect(result).toBe(padding); + }); + + it("should split the derivation path", () => { + // GIVEN + const path = "44'/60/0/0/0"; + + // WHEN + const result = DerivationPathUtils.splitPath(path); + + // THEN + expect(result).toStrictEqual([ + 44 + DerivationPathUtils.padding, + 60, + 0, + 0, + 0, + ]); + }); + + it("should split the derivation path with hardened path", () => { + // GIVEN + const path = "44'/60'/0'/0'/1"; + + // WHEN + const result = DerivationPathUtils.splitPath(path); + + // THEN + expect(result).toStrictEqual([ + 44 + DerivationPathUtils.padding, + 60 + DerivationPathUtils.padding, + 0 + DerivationPathUtils.padding, + 0 + DerivationPathUtils.padding, + 1, + ]); + }); + + it("should split the derivation path with custom path", () => { + // GIVEN + const path = "44'/60'/5/4/3"; + + // WHEN + const result = DerivationPathUtils.splitPath(path); + + // THEN + expect(result).toStrictEqual([ + 44 + DerivationPathUtils.padding, + 60 + DerivationPathUtils.padding, + 5, + 4, + 3, + ]); + }); + + it("should throw an error if invalid number provided", () => { + // GIVEN + const path = "44'/60'/zzz/4/3"; + + // WHEN + const result = () => DerivationPathUtils.splitPath(path); + + // THEN + expect(result).toThrow(new Error("invalid number provided")); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/shared/utils/DerivationPathUtils.ts b/packages/signer/keyring-eth/src/internal/shared/utils/DerivationPathUtils.ts new file mode 100644 index 000000000..d9d14bd92 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/shared/utils/DerivationPathUtils.ts @@ -0,0 +1,19 @@ +export class DerivationPathUtils { + static splitPath(path: string): number[] { + const result: number[] = []; + const components = path.split("/"); + components.forEach((element) => { + let number = parseInt(element, 10); + if (isNaN(number)) { + throw new Error("invalid number provided"); + } + if (element.length > 1 && element[element.length - 1] === "'") { + number += this.padding; + } + result.push(number); + }); + return result; + } + + static padding = 0x80000000; +} diff --git a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV5TransactionMapper.test.ts b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV5TransactionMapper.test.ts index 38c812352..5a2fe6eaa 100644 --- a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV5TransactionMapper.test.ts +++ b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV5TransactionMapper.test.ts @@ -26,6 +26,9 @@ describe("EthersV5TransactionMapper", () => { value: EthersV5BigNumber.from(0), data: "0x", }; + const serialized = new Uint8Array([ + 0xc9, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01, 0x80, 0x80, + ]); // WHEN const result = mapper.map(transaction); @@ -33,9 +36,12 @@ describe("EthersV5TransactionMapper", () => { // THEN expect(result).toEqual( Just({ - chainId: 1, - to: undefined, - data: "0x", + subset: { + chainId: 1, + to: undefined, + data: "0x", + }, + serialized, }), ); }); @@ -50,6 +56,9 @@ describe("EthersV5TransactionMapper", () => { data: "0x", to: "0x", }; + const serialized = new Uint8Array([ + 0xc9, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01, 0x80, 0x80, + ]); // WHEN const result = mapper.map(transaction); @@ -57,9 +66,12 @@ describe("EthersV5TransactionMapper", () => { // THEN expect(result).toEqual( Just({ - chainId: 1, - to: "0x", - data: "0x", + subset: { + chainId: 1, + to: "0x", + data: "0x", + }, + serialized, }), ); }); @@ -81,6 +93,9 @@ describe("EthersV5TransactionMapper", () => { maxFeePerGas: EthersV5BigNumber.from(0), maxPriorityFeePerGas: EthersV5BigNumber.from(0), }; + const serialized = new Uint8Array([ + 0xc9, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x01, 0x80, 0x80, + ]); // WHEN const result = mapper.map(transaction); @@ -88,9 +103,12 @@ describe("EthersV5TransactionMapper", () => { // THEN expect(result).toEqual( Just({ - chainId: 1, - to: undefined, - data: "0x", + subset: { + chainId: 1, + to: undefined, + data: "0x", + }, + serialized, }), ); }); diff --git a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV5TransactionMapper.ts b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV5TransactionMapper.ts index 93774cefe..29e2d00fd 100644 --- a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV5TransactionMapper.ts +++ b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV5TransactionMapper.ts @@ -1,22 +1,43 @@ -import { TransactionSubset } from "@ledgerhq/context-module"; -import { BigNumber, Transaction as EthersV5Transaction } from "ethers-v5"; +import { + BigNumber, + ethers, + Transaction as EthersV5Transaction, +} from "ethers-v5"; import { injectable } from "inversify"; import { Just, Maybe, Nothing } from "purify-ts"; import { Transaction } from "@api/index"; +import { TransactionMapperResult } from "./model/TransactionMapperResult"; import { TransactionMapper } from "./TransactionMapper"; @injectable() export class EthersV5TransactionMapper implements TransactionMapper { constructor() {} - map(transaction: Transaction): Maybe { + map(transaction: Transaction): Maybe { if (this.isEthersV5Transaction(transaction)) { - return Just({ - chainId: transaction.chainId, + // ensure that we have a valid non signed transaction + const txUnsigned = { to: transaction.to, + nonce: transaction.nonce, + gasLimit: transaction.gasLimit, + gasPrice: transaction.gasPrice, data: transaction.data, + value: transaction.value, + chainId: transaction.chainId, + }; + const serialized = ethers.utils.arrayify( + ethers.utils.serializeTransaction(txUnsigned), + ); + + return Just({ + subset: { + chainId: transaction.chainId, + to: transaction.to, + data: transaction.data, + }, + serialized, }); } @@ -31,20 +52,12 @@ export class EthersV5TransactionMapper implements TransactionMapper { typeof tx === "object" && tx !== null && (tx.to === undefined || typeof tx.to === "string") && - (tx.from === undefined || typeof tx.from === "string") && typeof tx.nonce === "number" && tx.gasLimit instanceof BigNumber && (tx.gasPrice === undefined || tx.gasPrice instanceof BigNumber) && typeof tx.data === "string" && tx.value instanceof BigNumber && - typeof tx.chainId === "number" && - (tx.r === undefined || typeof tx.r === "string") && - (tx.s === undefined || typeof tx.s === "string") && - (tx.v === undefined || typeof tx.v === "number") && - (tx.type === undefined || typeof tx.type === "number") && - (tx.maxFeePerGas === undefined || tx.maxFeePerGas instanceof BigNumber) && - (tx.maxPriorityFeePerGas === undefined || - tx.maxPriorityFeePerGas instanceof BigNumber) + typeof tx.chainId === "number" ); } } diff --git a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV6TransactionMapper.test.ts b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV6TransactionMapper.test.ts index f94185f7e..203260112 100644 --- a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV6TransactionMapper.test.ts +++ b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV6TransactionMapper.test.ts @@ -19,6 +19,9 @@ describe("EthersV6TransactionMapper", () => { transaction.chainId = 1n; transaction.nonce = 0; transaction.data = "0x"; + const serialized = new Uint8Array([ + 2, 201, 1, 128, 128, 128, 128, 128, 128, 128, 192, + ]); // WHEN const result = mapper.map(transaction); @@ -26,9 +29,12 @@ describe("EthersV6TransactionMapper", () => { // THEN expect(result).toEqual( Just({ - chainId: 1, - to: undefined, - data: "0x", + subset: { + chainId: 1, + to: undefined, + data: "0x", + }, + serialized, }), ); }); @@ -40,6 +46,11 @@ describe("EthersV6TransactionMapper", () => { transaction.nonce = 0; transaction.data = "0x"; transaction.to = "0x0123456789abcdef0123456789abcdef01234567"; + const serialized = new Uint8Array([ + 0x02, 0xdd, 0x01, 0x80, 0x80, 0x80, 0x80, 0x94, 0x01, 0x23, 0x45, 0x67, + 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, + 0x01, 0x23, 0x45, 0x67, 0x80, 0x80, 0xc0, + ]); // WHEN const result = mapper.map(transaction); @@ -47,9 +58,12 @@ describe("EthersV6TransactionMapper", () => { // THEN expect(result).toEqual( Just({ - chainId: 1, - to: "0x0123456789abcDEF0123456789abCDef01234567", - data: "0x", + subset: { + chainId: 1, + to: "0x0123456789abcDEF0123456789abCDef01234567", + data: "0x", + }, + serialized, }), ); }); @@ -63,13 +77,13 @@ describe("EthersV6TransactionMapper", () => { transaction.nonce = 0; transaction.gasLimit = 0n; transaction.gasPrice = 0n; - transaction.maxPriorityFeePerGas = 0n; - transaction.maxFeePerGas = 0n; transaction.value = 0n; transaction.chainId = 1n; - transaction.accessList = []; - transaction.maxFeePerBlobGas = 0n; - transaction.blobs = []; + const serialized = new Uint8Array([ + 0xdd, 0x80, 0x80, 0x80, 0x94, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, + 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, + 0x67, 0x80, 0x80, 0x01, 0x80, 0x80, + ]); // WHEN const result = mapper.map(transaction); @@ -77,9 +91,12 @@ describe("EthersV6TransactionMapper", () => { // THEN expect(result).toEqual( Just({ - chainId: 1, - to: "0x0123456789abcDEF0123456789abCDef01234567", - data: "0x", + subset: { + chainId: 1, + to: "0x0123456789abcDEF0123456789abCDef01234567", + data: "0x", + }, + serialized, }), ); }); diff --git a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV6TransactionMapper.ts b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV6TransactionMapper.ts index d27a11fdb..4745a9307 100644 --- a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV6TransactionMapper.ts +++ b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/EthersV6TransactionMapper.ts @@ -1,20 +1,24 @@ -import { TransactionSubset } from "@ledgerhq/context-module"; -import { Transaction as EthersV6Transaction } from "ethers-v6"; +import { getBytes, Transaction as EthersV6Transaction } from "ethers-v6"; import { injectable } from "inversify"; import { Just, Maybe, Nothing } from "purify-ts"; import { Transaction } from "@api/index"; +import { TransactionMapperResult } from "./model/TransactionMapperResult"; import { TransactionMapper } from "./TransactionMapper"; @injectable() export class EthersV6TransactionMapper implements TransactionMapper { - map(transaction: Transaction): Maybe { + map(transaction: Transaction): Maybe { if (this.isEthersV6Transaction(transaction)) { + const serialized = getBytes(transaction.unsignedSerialized); return Just({ - chainId: Number(transaction.chainId.toString()), - to: transaction.to ?? undefined, - data: transaction.data, + subset: { + chainId: Number(transaction.chainId.toString()), + to: transaction.to ?? undefined, + data: transaction.data, + }, + serialized, }); } diff --git a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/TransactionMapper.ts b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/TransactionMapper.ts index 9a2ca5493..d14a90243 100644 --- a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/TransactionMapper.ts +++ b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/TransactionMapper.ts @@ -1,8 +1,9 @@ -import { TransactionSubset } from "@ledgerhq/context-module"; import { Maybe } from "purify-ts"; import { Transaction } from "@api/index"; +import { TransactionMapperResult } from "./model/TransactionMapperResult"; + export interface TransactionMapper { - map(transaction: Transaction): Maybe; + map(transaction: Transaction): Maybe; } diff --git a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/TransactionMapperService.ts b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/TransactionMapperService.ts index 391a2a661..9a1b8f39a 100644 --- a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/TransactionMapperService.ts +++ b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/TransactionMapperService.ts @@ -1,10 +1,10 @@ -import { TransactionSubset } from "@ledgerhq/context-module"; import { injectable, multiInject } from "inversify"; import { Either, Left, Right } from "purify-ts"; import { Transaction } from "@api/index"; import { transactionTypes } from "@internal/transaction/di/transactionTypes"; +import { TransactionMapperResult } from "./model/TransactionMapperResult"; import { TransactionMapper } from "./TransactionMapper"; @injectable() @@ -20,7 +20,7 @@ export class TransactionMapperService { mapTransactionToSubset( transaction: Transaction, - ): Either { + ): Either { for (const mapper of this._mappers) { const result = mapper.map(transaction); if (result.isJust()) { diff --git a/packages/signer/keyring-eth/src/internal/transaction/service/mapper/model/TransactionMapperResult.ts b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/model/TransactionMapperResult.ts new file mode 100644 index 000000000..832d67fe1 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/transaction/service/mapper/model/TransactionMapperResult.ts @@ -0,0 +1,13 @@ +import { TransactionSubset } from "@ledgerhq/context-module"; + +export type TransactionMapperResult = { + /** + * transaction attributes used for clear signing + */ + subset: TransactionSubset; + + /** + * serialized transaction in Uint8Array format + */ + serialized: Uint8Array; +}; diff --git a/packages/signer/keyring-eth/src/internal/typed-data/di/typedDataModule.ts b/packages/signer/keyring-eth/src/internal/typed-data/di/typedDataModule.ts index 3a42f0b36..4cbd92f97 100644 --- a/packages/signer/keyring-eth/src/internal/typed-data/di/typedDataModule.ts +++ b/packages/signer/keyring-eth/src/internal/typed-data/di/typedDataModule.ts @@ -1,7 +1,7 @@ import { ContainerModule } from "inversify"; import { typedDataTypes } from "@internal/typed-data/di/typedDataTypes"; -import { TypedDataParserService } from "@internal/typed-data/service/TypedDataParserService"; +import { DefaultTypedDataParserService } from "@internal/typed-data/service/DefaultTypedDataParserService"; import { SignTypedDataUseCase } from "@internal/typed-data/use-case/SignTypedDataUseCase"; export const typedDataModuleFactory = () => @@ -16,6 +16,8 @@ export const typedDataModuleFactory = () => _onDeactivation, ) => { bind(typedDataTypes.SignTypedDataUseCase).to(SignTypedDataUseCase); - bind(typedDataTypes.TypedDataParserService).to(TypedDataParserService); + bind(typedDataTypes.TypedDataParserService).to( + DefaultTypedDataParserService, + ); }, ); diff --git a/packages/signer/keyring-eth/src/internal/typed-data/model/Types.ts b/packages/signer/keyring-eth/src/internal/typed-data/model/Types.ts new file mode 100644 index 000000000..36c1a1b3a --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/typed-data/model/Types.ts @@ -0,0 +1,57 @@ +import { Maybe } from "purify-ts"; + +export type StructName = string; +export type FieldName = string; +export type FieldType = PrimitiveType | ArrayType | StructType; +export type PrimitiveTypeName = + | "int" + | "uint" + | "address" + | "bytes" + | "string" + | "bool"; + +// Basic type (address, bool, uint256, etc) +export class PrimitiveType { + constructor( + public typeName: string, + public name: PrimitiveTypeName, + public size: Maybe, + ) {} +} + +// Arrays +export class ArrayType { + constructor( + public typeName: string, + public rootType: PrimitiveType | StructType, + public rowType: string, + public count: Maybe, + public levels: Array>, + ) {} +} + +// Custom structure +export class StructType { + constructor(public typeName: string) {} +} + +// Typed data field value and metadata +export interface TypedDataValue { + path: string; + type: string; + value: TypedDataValueRoot | TypedDataValueArray | TypedDataValueField; +} + +// The value is a message root. This is usually the primaryType name. +export class TypedDataValueRoot { + constructor(public root: string) {} +} +// The value is an array. Represents the array length. +export class TypedDataValueArray { + constructor(public length: number) {} +} +// The value is a field of any type. Contains the encoded value as a byte array. +export class TypedDataValueField { + constructor(public data: Uint8Array) {} +} diff --git a/packages/signer/keyring-eth/src/internal/typed-data/service/DefaultTypedDataParserService.ts b/packages/signer/keyring-eth/src/internal/typed-data/service/DefaultTypedDataParserService.ts new file mode 100644 index 000000000..80cd928b7 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/typed-data/service/DefaultTypedDataParserService.ts @@ -0,0 +1,16 @@ +import { injectable } from "inversify"; +import { Either } from "purify-ts"; + +import { TypedData } from "@api/model/TypedData"; +import { TypedDataValue } from "@internal/typed-data/model/Types"; + +import { TypedDataParser } from "./TypedDataParser"; +import { TypedDataParserService } from "./TypedDataParserService"; + +@injectable() +export class DefaultTypedDataParserService implements TypedDataParserService { + parse(data: TypedData): Either> { + const parser = new TypedDataParser(data.types); + return parser.parse(data.primaryType, data.message); + } +} diff --git a/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataEncoder.test.ts b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataEncoder.test.ts new file mode 100644 index 000000000..adaa1a353 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataEncoder.test.ts @@ -0,0 +1,445 @@ +import { Just, Nothing } from "purify-ts"; + +import { PrimitiveType } from "@internal/typed-data/model/Types"; + +import { encodeTypedDataValue } from "./TypedDataEncoder"; + +describe("TypedDataEncoder", () => { + const ADDRESS_TYPE = new PrimitiveType("address", "address", Nothing); + const BYTES_TYPE = new PrimitiveType("bytes", "bytes", Nothing); + const BYTES_TYPE_WITH_LENGTH = new PrimitiveType("bytes", "bytes", Just(7)); + const STRING_TYPE = new PrimitiveType("string", "string", Nothing); + const BOOL_TYPE = new PrimitiveType("bool", "bool", Nothing); + const I8_TYPE = new PrimitiveType("int8", "int", Just(1)); + const I16_TYPE = new PrimitiveType("int16", "int", Just(2)); + const I32_TYPE = new PrimitiveType("int32", "int", Just(4)); + const I64_TYPE = new PrimitiveType("int64", "int", Just(8)); + const I128_TYPE = new PrimitiveType("int128", "int", Just(16)); + const I256_TYPE = new PrimitiveType("int256", "int", Just(32)); + const U8_TYPE = new PrimitiveType("uint8", "uint", Just(1)); + const U16_TYPE = new PrimitiveType("uint16", "uint", Just(2)); + const U32_TYPE = new PrimitiveType("uint32", "uint", Just(4)); + const U64_TYPE = new PrimitiveType("uint64", "uint", Just(8)); + const U128_TYPE = new PrimitiveType("uint128", "uint", Just(16)); + const U256_TYPE = new PrimitiveType("uint256", "uint", Just(32)); + + it("Encode an address", () => { + // GIVEN + const addresses = [ + "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF", + "0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57", + "0xBdaBea57B0BDABeA57b0bdABEA57b0BDabEa57", + ]; + // WHEN + const encoded = addresses.map((address) => + encodeTypedDataValue(ADDRESS_TYPE, address), + ); + // THEN + const expected = [ + Just( + Uint8Array.from([ + 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, + 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, + ]), + ), + Just( + Uint8Array.from([ + 0xcd, 0x2a, 0x3d, 0x9f, 0x93, 0x8e, 0x13, 0xcd, 0x94, 0x7e, 0xc0, + 0x5a, 0xbc, 0x7f, 0xe7, 0x34, 0xdf, 0x8d, 0xd8, 0x26, + ]), + ), + Just( + Uint8Array.from([ + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, + 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, + ]), + ), + Just( + Uint8Array.from([ + 0xb0, 0xbd, 0xab, 0xea, 0x57, 0xb0, 0xbd, 0xab, 0xea, 0x57, 0xb0, + 0xbd, 0xab, 0xea, 0x57, 0xb0, 0xbd, 0xab, 0xea, 0x57, + ]), + ), + Just( + Uint8Array.from([ + 0xbd, 0xab, 0xea, 0x57, 0xb0, 0xbd, 0xab, 0xea, 0x57, 0xb0, 0xbd, + 0xab, 0xea, 0x57, 0xb0, 0xbd, 0xab, 0xea, 0x57, + ]), + ), + ]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode an address with invalid size", () => { + // GIVEN + const address = "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccCff"; + // WHEN + const encoded = encodeTypedDataValue(ADDRESS_TYPE, address); + // THEN + expect(encoded).toStrictEqual(Nothing); + }); + + it("Encode an address with invalid value", () => { + // GIVEN + const address = "0xbonjourcCCCCcCCCCCCcCcCccCcCCCcCcccccccC"; + // WHEN + const encoded = encodeTypedDataValue(ADDRESS_TYPE, address); + // THEN + expect(encoded).toStrictEqual(Nothing); + }); + + it("Encode an address with invalid value type", () => { + // GIVEN + const address = 17; + // WHEN + const encoded = encodeTypedDataValue(ADDRESS_TYPE, address); + // THEN + expect(encoded).toStrictEqual(Nothing); + }); + + it("Encode an byte array", () => { + // GIVEN + const bytes = [ + "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcc", + "0x13CD947Ec05AbC7FE734Df8DD826", + "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeFbeeF", + "0xBDabEa57", + ]; + // WHEN + const encoded = bytes.map((b) => encodeTypedDataValue(BYTES_TYPE, b)); + // THEN + const expected = [ + Just( + Uint8Array.from([ + 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, + 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, + ]), + ), + Just( + Uint8Array.from([ + 0x13, 0xcd, 0x94, 0x7e, 0xc0, 0x5a, 0xbc, 0x7f, 0xe7, 0x34, 0xdf, + 0x8d, 0xd8, 0x26, + ]), + ), + Just( + Uint8Array.from([ + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, + 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xbe, 0xef, + ]), + ), + Just(Uint8Array.from([0xbd, 0xab, 0xea, 0x57])), + ]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode an byte array with size", () => { + // GIVEN + const bytes = ["0x13CD947Ec05AbC", "0x13CD947Ec05A", "0x13CD947Ec05AbCde"]; + // WHEN + const encoded = bytes.map((b) => + encodeTypedDataValue(BYTES_TYPE_WITH_LENGTH, b), + ); + // THEN + const expected = [ + Just(Uint8Array.from([0x13, 0xcd, 0x94, 0x7e, 0xc0, 0x5a, 0xbc])), + Just(Uint8Array.from([0x13, 0xcd, 0x94, 0x7e, 0xc0, 0x5a])), + Nothing, + ]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode a string", () => { + // GIVEN + const strings = [ + "Hello, Bob!", + '"did:ethr:0xf7398bacf610bb4e3b567811279fcb3c41919f89"', + '"2021-03-04T21:08:22.615Z"^^', + "_", + ]; + // WHEN + const encoded = strings.map((str) => + encodeTypedDataValue(STRING_TYPE, str), + ); + // THEN + const expected = [ + Just( + Uint8Array.from([ + 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0x42, 0x6f, 0x62, 0x21, + ]), + ), + Just( + Uint8Array.from([ + 0x22, 0x64, 0x69, 0x64, 0x3a, 0x65, 0x74, 0x68, 0x72, 0x3a, 0x30, + 0x78, 0x66, 0x37, 0x33, 0x39, 0x38, 0x62, 0x61, 0x63, 0x66, 0x36, + 0x31, 0x30, 0x62, 0x62, 0x34, 0x65, 0x33, 0x62, 0x35, 0x36, 0x37, + 0x38, 0x31, 0x31, 0x32, 0x37, 0x39, 0x66, 0x63, 0x62, 0x33, 0x63, + 0x34, 0x31, 0x39, 0x31, 0x39, 0x66, 0x38, 0x39, 0x22, + ]), + ), + Just( + Uint8Array.from([ + 0x22, 0x32, 0x30, 0x32, 0x31, 0x2d, 0x30, 0x33, 0x2d, 0x30, 0x34, + 0x54, 0x32, 0x31, 0x3a, 0x30, 0x38, 0x3a, 0x32, 0x32, 0x2e, 0x36, + 0x31, 0x35, 0x5a, 0x22, 0x5e, 0x5e, 0x3c, 0x68, 0x74, 0x74, 0x70, + 0x3a, 0x2f, 0x2f, 0x77, 0x77, 0x77, 0x2e, 0x77, 0x33, 0x2e, 0x6f, + 0x72, 0x67, 0x2f, 0x32, 0x30, 0x30, 0x31, 0x2f, 0x58, 0x4d, 0x4c, + 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x23, 0x64, 0x61, 0x74, 0x65, + 0x54, 0x69, 0x6d, 0x65, 0x3e, + ]), + ), + Just( + Uint8Array.from([ + 0x5f, 0x3c, 0x64, 0x69, 0x64, 0x3a, 0x6b, 0x65, 0x79, 0x3a, 0x7a, + 0x36, 0x4d, 0x6b, 0x67, 0x46, 0x6e, 0x65, 0x61, 0x61, 0x4d, 0x6a, + 0x4e, 0x36, 0x7a, 0x79, 0x62, 0x71, 0x4c, 0x4e, 0x58, 0x67, 0x74, + 0x34, 0x59, 0x66, 0x6d, 0x56, 0x78, 0x32, 0x58, 0x5a, 0x68, 0x7a, + 0x50, 0x64, 0x44, 0x79, 0x6b, 0x34, 0x5a, 0x4b, 0x38, 0x31, 0x64, + 0x61, 0x48, 0x5a, 0x3e, + ]), + ), + ]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode a string with invalid value type", () => { + // GIVEN + const string = 17; + // WHEN + const encoded = encodeTypedDataValue(STRING_TYPE, string); + // THEN + expect(encoded).toStrictEqual(Nothing); + }); + + it("Encode a signed number", () => { + // GIVEN + const signed = [ + { type: I8_TYPE, value: 127 }, + { type: I8_TYPE, value: -128 }, + { type: I16_TYPE, value: "32767" }, + { type: I16_TYPE, value: "-32768" }, + { type: I32_TYPE, value: "0x7FFFFFFF" }, + { type: I32_TYPE, value: "-2147483648" }, + { type: I64_TYPE, value: 9223372036854775807n }, + { type: I64_TYPE, value: -9223372036854775808n }, + { type: I128_TYPE, value: "170141183460469231731687303715884105727" }, + { type: I128_TYPE, value: "-170141183460469231731687303715884105728" }, + { + type: I256_TYPE, + value: + 57896044618658097711785492504343953926634992332820282019728792003956564819967n, + }, + { + type: I256_TYPE, + value: + -57896044618658097711785492504343953926634992332820282019728792003956564819968n, + }, + ]; + // WHEN + const encoded = signed.map((s) => encodeTypedDataValue(s.type, s.value)); + // THEN + const expected = [ + Just(Uint8Array.from([0x7f])), + Just(Uint8Array.from([0x80])), + Just(Uint8Array.from([0x7f, 0xff])), + Just(Uint8Array.from([0x80, 0x00])), + Just(Uint8Array.from([0x7f, 0xff, 0xff, 0xff])), + Just(Uint8Array.from([0x80, 0x00, 0x00, 0x00])), + Just(Uint8Array.from([0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])), + Just(Uint8Array.from([0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])), + Just( + Uint8Array.from([ + 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, + ]), + ), + Just( + Uint8Array.from([ + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + ]), + ), + Just( + Uint8Array.from([ + 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + ]), + ), + Just( + Uint8Array.from([ + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]), + ), + ]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode a signed number, out of bounds", () => { + // GIVEN + const signed = [ + { type: I8_TYPE, value: "" }, + { type: I8_TYPE, value: 128 }, + { type: I8_TYPE, value: -129 }, + { type: I16_TYPE, value: "32768" }, + { type: I16_TYPE, value: "-32769" }, + { type: I32_TYPE, value: "0xFFFFFFFF" }, + { type: I32_TYPE, value: "-2147483649" }, + { type: I64_TYPE, value: 9223372036854775808n }, + { type: I64_TYPE, value: -9223372036854775809n }, + { type: I128_TYPE, value: "170141183460469231731687303715884105728" }, + { type: I128_TYPE, value: "-170141183460469231731687303715884105729" }, + { + type: I256_TYPE, + value: + 57896044618658097711785492504343953926634992332820282019728792003956564819968n, + }, + { + type: I256_TYPE, + value: + -57896044618658097711785492504343953926634992332820282019728792003956564819969n, + }, + ]; + // WHEN + const encoded = signed.map((s) => encodeTypedDataValue(s.type, s.value)); + // THEN + const expected = [ + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + ]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode an unsigned number", () => { + // GIVEN + const unsigned = [ + { type: U8_TYPE, value: 0 }, + { type: U8_TYPE, value: 255 }, + { type: U16_TYPE, value: "65535" }, + { type: U32_TYPE, value: "0xFFFFFFFF" }, + { type: U64_TYPE, value: 18446744073709551615n }, + { type: U128_TYPE, value: "340282366920938463463374607431768211455" }, + { + type: U256_TYPE, + value: + 115792089237316195423570985008687907853269984665640564039457584007913129639935n, + }, + ]; + // WHEN + const encoded = unsigned.map((s) => encodeTypedDataValue(s.type, s.value)); + // THEN + const expected = [ + Just(Uint8Array.from([0x00])), + Just(Uint8Array.from([0xff])), + Just(Uint8Array.from([0xff, 0xff])), + Just(Uint8Array.from([0xff, 0xff, 0xff, 0xff])), + Just(Uint8Array.from([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])), + Just( + Uint8Array.from([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, + ]), + ), + Just( + Uint8Array.from([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + ]), + ), + ]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode an unsigned number, out of bound", () => { + // GIVEN + const unsigned = [ + { type: U8_TYPE, value: "" }, + { type: U8_TYPE, value: 0.5 }, + { type: U8_TYPE, value: -1 }, + { type: U8_TYPE, value: 256 }, + { type: U16_TYPE, value: "65536" }, + { type: U32_TYPE, value: "0x100000000" }, + { type: U64_TYPE, value: 18446744073709551616n }, + { type: U128_TYPE, value: "340282366920938463463374607431768211456" }, + { + type: U256_TYPE, + value: + 115792089237316195423570985008687907853269984665640564039457584007913129639936n, + }, + ]; + // WHEN + const encoded = unsigned.map((s) => encodeTypedDataValue(s.type, s.value)); + // THEN + const expected = [ + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + Nothing, + ]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode a boolean", () => { + // GIVEN + const bools = [false, true, 0, 1, "0", "0x1"]; + // WHEN + const encoded = bools.map((b) => encodeTypedDataValue(BOOL_TYPE, b)); + // THEN + const expected = [ + Just(Uint8Array.from([0x00])), + Just(Uint8Array.from([0x01])), + Just(Uint8Array.from([0x00])), + Just(Uint8Array.from([0x01])), + Just(Uint8Array.from([0x00])), + Just(Uint8Array.from([0x01])), + ]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode a boolean, out of bounds", () => { + // GIVEN + const bools = [-1, 2]; + // WHEN + const encoded = bools.map((b) => encodeTypedDataValue(BOOL_TYPE, b)); + // THEN + const expected = [Nothing, Nothing]; + expect(encoded).toStrictEqual(expected); + }); + + it("Encode an invalid data type", () => { + // GIVEN + const data = [ + { type: U8_TYPE, value: true }, + { type: I8_TYPE, value: {} }, + { type: BOOL_TYPE, value: undefined }, + { type: STRING_TYPE, value: 42 }, + { type: BYTES_TYPE, value: 42 }, + { type: ADDRESS_TYPE, value: false }, + ]; + // WHEN + const encoded = data.map((d) => encodeTypedDataValue(d.type, d.value)); + // THEN + const expected = [Nothing, Nothing, Nothing, Nothing, Nothing, Nothing]; + expect(encoded).toStrictEqual(expected); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataEncoder.ts b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataEncoder.ts new file mode 100644 index 000000000..4b70570ba --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataEncoder.ts @@ -0,0 +1,128 @@ +import { hexaStringToBuffer } from "@ledgerhq/device-sdk-core"; +import { Just, Maybe, Nothing } from "purify-ts"; + +import { + PrimitiveType, + PrimitiveTypeName, +} from "@internal/typed-data/model/Types"; + +/** + * Encodes a typed data value according to its type. + * @param type The type of the value to encode. + * @param value The value to encode into a byte array. + * @returns An optional Uint8Array containing the encoded value, if the value was encodable for the given type. + */ +export function encodeTypedDataValue( + type: PrimitiveType, + value: unknown, +): Maybe { + switch (type.name) { + case "string": + // Encode the value as a UTF-8 string + return typeof value !== "string" + ? Nothing + : Just(new TextEncoder().encode(value)); + case "bytes": + case "address": + // Encode the hexadecimal string as bytes + return typeof value !== "string" + ? Nothing + : encodeTypedDataBytes(type, value); + case "bool": + case "uint": + case "int": + // Convert boolean values to numbers, so it can then be encoded as a number + if (type.name === "bool" && typeof value === "boolean") { + value = Number(value); + } + // Encode the value as a number + return typeof value !== "string" && + typeof value !== "number" && + typeof value !== "bigint" + ? Nothing + : encodeTypedDataNumber( + type.name, + type.size.mapOrDefault((s) => s * 8, 1), // Size in bits + value, + ); + } +} + +function encodeTypedDataBytes( + type: PrimitiveType, + value: string, +): Maybe { + const maxSize = type.name === "address" ? Just(20) : type.size; + const buffer = Maybe.fromNullable(hexaStringToBuffer(value)); + return buffer.filter((b) => maxSize.mapOrDefault((s) => b.length <= s, true)); +} + +function encodeTypedDataNumber( + type: PrimitiveTypeName, + sizeInBits: number, + value: string | number | bigint, +): Maybe { + // Convert the value to a bigint + let bigintValue: bigint; + switch (typeof value) { + case "bigint": + bigintValue = value; + break; + case "number": + if (!Number.isInteger(value)) { + return Nothing; + } + bigintValue = BigInt(value); + break; + case "string": + if (value.length === 0) { + return Nothing; + } + try { + bigintValue = BigInt(value); + } catch (e: unknown) { + return Nothing; + } + break; + } + // Check the bounds of the value and convert it to two's complement if it is signed and negative + const signed = type === "int"; + return checkBoundsAndConvert(bigintValue, BigInt(sizeInBits), signed).chain( + (converted) => + Maybe.fromNullable(hexaStringToBuffer(converted.toString(16))), + ); +} + +/** + * Checks the bounds of a signed or unsigned integer value and converts it to two's complement if it is signed and negative. + * @param value The value to check and convert. + * @param sizeInBits The size of the value in bits. + * @param signed Whether the value is signed or unsigned. + * @returns The converted value, or null if the value is out of bounds. + */ +function checkBoundsAndConvert( + value: bigint, + sizeInBits: bigint, + signed: boolean, +): Maybe { + if (!signed) { + // Check if the value is within the bounds of an unsigned integer + return value >= 0n && value < 1n << sizeInBits ? Just(value) : Nothing; + } + + // Check if the value is within the bounds of a signed integer + const limit = 1n << (sizeInBits - 1n); + if (value >= limit || value < -limit) { + return Nothing; + } + + // Convert the value to two's complement if it is negative + // https://en.wikipedia.org/wiki/Two%27s_complement + if (value < 0n) { + const mask = (1n << sizeInBits) - 1n; + value = -value; + value = (~value & mask) + 1n; + } + + return Just(value); +} diff --git a/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParser.test.ts b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParser.test.ts new file mode 100644 index 000000000..c170c7e47 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParser.test.ts @@ -0,0 +1,543 @@ +import { hexaStringToBuffer } from "@ledgerhq/device-sdk-core"; +import { Just, Nothing, Right } from "purify-ts"; + +import { TypedData } from "@api/model/TypedData"; +import { + ArrayType, + PrimitiveType, + StructType, + TypedDataValueArray, + TypedDataValueField, + TypedDataValueRoot, +} from "@internal/typed-data/model/Types"; + +import { TypedDataParser } from "./TypedDataParser"; + +describe("TypedDataParser - types parsing", () => { + it("Parse primitive types bytes", () => { + // GIVEN + const types = { + TestStruct: [ + { name: "test1", type: "bytes" }, + { name: "test2", type: "bytes1" }, + { name: "test3", type: "bytes2" }, + { name: "test4", type: "bytes31" }, + { name: "test5", type: "bytes32" }, + ], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const expected = { + TestStruct: { + test1: new PrimitiveType("bytes", "bytes", Nothing), + test2: new PrimitiveType("bytes1", "bytes", Just(1)), + test3: new PrimitiveType("bytes2", "bytes", Just(2)), + test4: new PrimitiveType("bytes31", "bytes", Just(31)), + test5: new PrimitiveType("bytes32", "bytes", Just(32)), + }, + }; + expect(parser.getStructDefinitions()).toStrictEqual(expected); + }); + + it("Parse primitive types bytes, out of bound", () => { + // GIVEN + const types = { + TestStruct: [ + { name: "invalid1", type: "bytes0" }, + { name: "invalid2", type: "bytes33" }, + ], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const expected = { + TestStruct: { + invalid1: new StructType("bytes0"), + invalid2: new StructType("bytes33"), + }, + }; + expect(parser.getStructDefinitions()).toStrictEqual(expected); + }); + + it("Parse primitive types number", () => { + // GIVEN + const types = { + TestStruct: [ + { name: "test1", type: "int8" }, + { name: "test2", type: "uint8" }, + { name: "test3", type: "int16" }, + { name: "test4", type: "uint32" }, + { name: "test5", type: "uint64" }, + { name: "test6", type: "int128" }, + { name: "test7", type: "int136" }, + { name: "test8", type: "int144" }, + { name: "test9", type: "uint240" }, + { name: "test10", type: "uint248" }, + { name: "test11", type: "uint256" }, + { name: "test12", type: "int256" }, + ], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const expected = { + TestStruct: { + test1: new PrimitiveType("int8", "int", Just(1)), + test2: new PrimitiveType("uint8", "uint", Just(1)), + test3: new PrimitiveType("int16", "int", Just(2)), + test4: new PrimitiveType("uint32", "uint", Just(4)), + test5: new PrimitiveType("uint64", "uint", Just(8)), + test6: new PrimitiveType("int128", "int", Just(16)), + test7: new PrimitiveType("int136", "int", Just(17)), + test8: new PrimitiveType("int144", "int", Just(18)), + test9: new PrimitiveType("uint240", "uint", Just(30)), + test10: new PrimitiveType("uint248", "uint", Just(31)), + test11: new PrimitiveType("uint256", "uint", Just(32)), + test12: new PrimitiveType("int256", "int", Just(32)), + }, + }; + expect(parser.getStructDefinitions()).toStrictEqual(expected); + }); + + it("Parse primitive types number, out of bound", () => { + // GIVEN + const types = { + TestStruct: [ + { name: "invalid1", type: "int0" }, + { name: "invalid2", type: "uint0" }, + { name: "invalid3", type: "int7" }, + { name: "invalid4", type: "int257" }, + { name: "invalid5", type: "uint257" }, + { name: "invalid6", type: "int512" }, + ], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const expected = { + TestStruct: { + invalid1: new StructType("int0"), + invalid2: new StructType("uint0"), + invalid3: new StructType("int7"), + invalid4: new StructType("int257"), + invalid5: new StructType("uint257"), + invalid6: new StructType("int512"), + }, + }; + expect(parser.getStructDefinitions()).toStrictEqual(expected); + }); + + it("Parse primitive types others", () => { + // GIVEN + const types = { + TestStruct: [ + { name: "test1", type: "address" }, + { name: "test2", type: "bool" }, + { name: "test3", type: "string" }, + ], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const expected = { + TestStruct: { + test1: new PrimitiveType("address", "address", Nothing), + test2: new PrimitiveType("bool", "bool", Nothing), + test3: new PrimitiveType("string", "string", Nothing), + }, + }; + expect(parser.getStructDefinitions()).toStrictEqual(expected); + }); + + it("Parse arrays", () => { + // GIVEN + const types = { + TestStruct: [ + { name: "test1", type: "address[]" }, + { name: "test2", type: "uint16[3]" }, + { name: "test3", type: "custom[2][][3]" }, + { name: "test4", type: "string[2][][3][]" }, + ], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const expected = { + TestStruct: { + test1: new ArrayType( + "address[]", + new PrimitiveType("address", "address", Nothing), + "address", + Nothing, + [Nothing], + ), + test2: new ArrayType( + "uint16[3]", + new PrimitiveType("uint16", "uint", Just(2)), + "uint16", + Just(3), + [Just(3)], + ), + test3: new ArrayType( + "custom[2][][3]", + new StructType("custom"), + "custom[2][]", + Just(3), + [Just(2), Nothing, Just(3)], + ), + test4: new ArrayType( + "string[2][][3][]", + new PrimitiveType("string", "string", Nothing), + "string[2][][3]", + Nothing, + [Just(2), Nothing, Just(3), Nothing], + ), + }, + }; + expect(parser.getStructDefinitions()).toStrictEqual(expected); + }); + + it("Parse custom struct", () => { + // GIVEN + const types = { + TestStruct: [{ name: "test", type: "MyCustomStructure" }], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const expected = { + TestStruct: { + test: new StructType("MyCustomStructure"), + }, + }; + expect(parser.getStructDefinitions()).toStrictEqual(expected); + }); +}); + +describe("TypedDataParser - message parsing", () => { + const MESSAGE: TypedData = { + domain: { + chainId: 5, + name: "Ether Mail", + verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + version: "1", + }, + message: { + contents: "Hello, Bob!", + from: { + name: "Cow", + wallets: [ + "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF", + ], + }, + to: [ + { + wallets: [ + "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + "0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57", + "0xB0B0b0b0b0b0B000000000000000000000000000", + ], + name: "Bob", + }, + ], + }, + primaryType: "Mail", + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + Mail: [ + { name: "from", type: "Person" }, + { name: "to", type: "Person[1]" }, + { name: "contents", type: "string" }, + ], + Person: [ + { name: "name", type: "string" }, + { name: "wallets", type: "address[]" }, + ], + }, + }; + + it("Parse an EIP712 message", () => { + // GIVEN + const types = MESSAGE.types; + const primaryType = MESSAGE.primaryType; + const message = MESSAGE.message; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + const expected = [ + { + path: "", + type: "", + value: new TypedDataValueRoot(primaryType), + }, + { + path: "from.name", + type: "string", + value: new TypedDataValueField(new TextEncoder().encode("Cow")), + }, + { + path: "from.wallets", + type: "address[]", + value: new TypedDataValueArray(2), + }, + { + path: "from.wallets.[]", + type: "address", + value: new TypedDataValueField( + hexaStringToBuffer("0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826")!, + ), + }, + { + path: "from.wallets.[]", + type: "address", + value: new TypedDataValueField( + hexaStringToBuffer("0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF")!, + ), + }, + { path: "to", type: "Person[1]", value: new TypedDataValueArray(1) }, + { + path: "to.[].name", + type: "string", + value: new TypedDataValueField(new TextEncoder().encode("Bob")), + }, + { + path: "to.[].wallets", + type: "address[]", + value: new TypedDataValueArray(3), + }, + { + path: "to.[].wallets.[]", + type: "address", + value: new TypedDataValueField( + hexaStringToBuffer("0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB")!, + ), + }, + { + path: "to.[].wallets.[]", + type: "address", + value: new TypedDataValueField( + hexaStringToBuffer("0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57")!, + ), + }, + { + path: "to.[].wallets.[]", + type: "address", + value: new TypedDataValueField( + hexaStringToBuffer("0xB0B0b0b0b0b0B000000000000000000000000000")!, + ), + }, + { + path: "contents", + type: "string", + value: new TypedDataValueField(new TextEncoder().encode("Hello, Bob!")), + }, + ]; + expect(parsed).toStrictEqual(Right(expected)); + }); + + it("Parse an EIP712 domain", () => { + // GIVEN + const types = MESSAGE.types; + const primaryType = "EIP712Domain"; + const message = MESSAGE.domain; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const expected = [ + { + path: "", + type: "", + value: new TypedDataValueRoot(primaryType), + }, + { + path: "name", + type: "string", + value: new TypedDataValueField(new TextEncoder().encode("Ether Mail")), + }, + { + path: "version", + type: "string", + value: new TypedDataValueField(new TextEncoder().encode("1")), + }, + { + path: "chainId", + type: "uint256", + value: new TypedDataValueField(Uint8Array.from([5])), + }, + { + path: "verifyingContract", + type: "address", + value: new TypedDataValueField( + hexaStringToBuffer("0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC")!, + ), + }, + ]; + const parsed = parser.parse(primaryType, message); + expect(parsed).toStrictEqual(Right(expected)); + }); + + it("Invalid primary type", () => { + // GIVEN + const types = MESSAGE.types; + const primaryType = "unknown"; + const message = MESSAGE.domain; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); + + it("Struct points to an unknown custom type", () => { + // GIVEN + const types = { + Mail: [{ name: "from", type: "Person" }], + }; + const primaryType = "Mail"; + const message = { + from: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); + + it("Array contains an unknown custom type", () => { + // GIVEN + const types = { + Mail: [{ name: "from", type: "Person[]" }], + }; + const primaryType = "Mail"; + const message = { + from: ["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); + + it("Invalid primitive value", () => { + // GIVEN + const types = { + Mail: [{ name: "from", type: "uint8" }], + }; + const primaryType = "Mail"; + const message = { + from: 3000, + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); + + it("Array instead of primitive value", () => { + // GIVEN + const types = { + Mail: [{ name: "from", type: "uint8" }], + }; + const primaryType = "Mail"; + const message = { + from: [42], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); + + it("Struct instead of primitive value", () => { + // GIVEN + const types = { + Mail: [{ name: "from", type: "uint8" }], + }; + const primaryType = "Mail"; + const message = { + from: { data: 42 }, + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); + + it("Struct value not a record", () => { + // GIVEN + const types = { + Mail: [{ name: "from", type: "Person" }], + Person: [{ name: "data", type: "uint8" }], + }; + const primaryType = "Mail"; + const message = { + from: 42, + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); + + it("Struct field not present in value", () => { + // GIVEN + const types = { + Mail: [{ name: "from", type: "uint8" }], + }; + const primaryType = "Mail"; + const message = { + to: 42, + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); + + it("Array value not an array", () => { + // GIVEN + const types = { + Mail: [{ name: "from", type: "uint8[]" }], + }; + const primaryType = "Mail"; + const message = { + from: 42, + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); + + it("Array value with invalid size", () => { + // GIVEN + const types = { + Mail: [{ name: "from", type: "uint8[3]" }], + }; + const primaryType = "Mail"; + const message = { + from: [42], + }; + // WHEN + const parser = new TypedDataParser(types); + // THEN + const parsed = parser.parse(primaryType, message); + expect(parsed.isLeft()).toStrictEqual(true); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParser.ts b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParser.ts new file mode 100644 index 000000000..a484fcbe0 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParser.ts @@ -0,0 +1,304 @@ +import { Either, Just, Left, Maybe, Nothing, Right } from "purify-ts"; + +import { TypedDataField } from "@api/model/TypedData"; +import { + ArrayType, + FieldName, + FieldType, + PrimitiveType, + StructName, + StructType, + TypedDataValue, + TypedDataValueArray, + TypedDataValueField, + TypedDataValueRoot, +} from "@internal/typed-data/model/Types"; + +import { encodeTypedDataValue } from "./TypedDataEncoder"; + +/** + * A parser for EIP-712 typed data messages. + * + * ```typescript + * const types = { + * Person: [ + * { name: 'name', type: 'string' }, + * { name: 'age', type: 'uint256' }, + * { name: "wallets", type: "address[]" }, + * ], + * }; + * const parser = new TypedDataParser(types); + * + * const message = { + * name: 'Alice', + * age: 30, + * wallets: [ + * "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + * "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + * ], + * }; + * const rootType = 'Person'; + * + * const result = parser.parse(rootType, message); + * ``` + */ +export class TypedDataParser { + private readonly structs: Record>; + + /** + * Creates a new instance of the TypedDataParser class. + * @param types The types to be used for parsing the message. + */ + constructor(types: Record>) { + // Parse the types to be used later for parsing a message. + const structs: Record> = {}; + for (const [typedName, typedData] of Object.entries(types)) { + const parsedTypedData: Record = {}; + for (const data of typedData) { + parsedTypedData[data.name] = this.parseType(data.type); + } + structs[typedName] = parsedTypedData; + } + this.structs = structs; + } + + /** + * Returns the parsed definitions of custom structs as defined in the types passed to the constructor. + * @returns The struct definitions. + */ + public getStructDefinitions(): Record< + StructName, + Record + > { + return this.structs; + } + + /** + * Parses a message according to the primary type and the types passed to the constructor. + * @param primaryType The root type of the message. + * @param message The message to parse. + * @returns An Either containing the parsed values or an error. + */ + public parse( + primaryType: string, + message: unknown, + ): Either> { + if (!this.isRecord(message)) { + return Left(new Error("Message is not a record")); + } + const values: Array = [ + { + path: "", + type: "", + value: new TypedDataValueRoot(primaryType), + }, + ]; + return this.visitValue(primaryType, message, "", (val) => values.push(val)) + ? Right(values) + : Left(new Error("Failed to parse")); + } + + private isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; + } + + /** + * Parses a type string description into a PrimitiveType, ArrayType, or StructType object. + * The description string should match https://eips.ethereum.org/EIPS/eip-712#definition-of-typed-structured-data-%F0%9D%95%8A + * Any string which doesn't match those patterns is considered a custom struct. + * @param type The type string to parse. + * @returns The parsed type object. + */ + private parseType(type: string): PrimitiveType | ArrayType | StructType { + return this.tryParsePrimitiveType(type).mapOrDefault( + (just) => just, + this.tryParseArrayType(type).mapOrDefault( + (just) => just, + new StructType(type), + ), + ); + } + + private tryParsePrimitiveType(type: string): Maybe { + // int8 to int256 and uint8 to uint256 + { + const match = type.match(/^(((u?)int)(\d+))$/); + if (match) { + const size = parseInt(match[4]!); + if (size % 8 !== 0 || size === 0 || size > 256) { + return Nothing; // Unsupported number + } + return Just( + new PrimitiveType( + match[1]!, // typeName such as uint64 + match[3] ? "uint" : "int", // name such as uint + Just(size / 8), // size in bytes such as 8 for an uint64 + ), + ); + } + } + + // bytes1 to bytes32, or bytes (dynamic size) + { + const match = type.match(/^((bytes)(\d*))$/); + if (match) { + const size = match[3] ? parseInt(match[3]) : null; + if (size !== null && (size === 0 || size > 32)) { + return Nothing; // Unsupported byte array + } + return Just( + new PrimitiveType( + match[1]!, // typename such as bytes32 + "bytes", // name + Maybe.fromNullable(size), // size in bytes, or null for a dynamic size + ), + ); + } + } + + // Other primitive types + if (type === "address" || type === "bool" || type === "string") { + return Just( + new PrimitiveType( + type, // typeName + type, // name + Nothing, // size not applicable for those types + ), + ); + } + + // Not a primitive type + return Nothing; + } + + private tryParseArrayType(type: string): Maybe { + // Try to match an array such as: foo[2][][3] + const match = type.match(/^([^[[]*)(((\[\d*\])*)\[\d*\])$/); + if (match) { + const matchLevels = [...match[2]!.matchAll(/\[(\d*)\]/g)]; + if (matchLevels && matchLevels.length > 0) { + const levels = matchLevels.map(([, size]) => + size ? Just(parseInt(size)) : Nothing, + ); + const rootType = this.tryParsePrimitiveType(match[1]!).mapOrDefault( + (just) => just, + new StructType(match[1]!), + ); + return Just( + new ArrayType( + type, // typeName such as: foo[2][][3] + rootType, // rootType such as: foo + match[1]! + match[3], // rowType such as: foo[2][] + levels[levels.length - 1]!, // rows count such as: 3 + levels, // All levels for that array (null for dynamic size), such as: [2, null, 3] + ), + ); + } + } + + // Not an array + return Nothing; + } + + /** + * Visits a value and its children recursively, parsing them into TypedDataValue objects. + * @param type The type of the value. + * @param value The value to visit. + * @param path The path of the value. + * @param callback The callback to call for each parsed value. + * @returns True if the value and its children were successfully parsed, false otherwise. + */ + private visitValue( + type: string, + value: unknown, + path: string, + callback: (parsedValue: TypedDataValue) => void, + ): boolean { + return ( + this.tryVisitStructValue(type, value, path, callback) || + this.tryVisitPrimitiveValue(type, value, path, callback) || + this.tryVisitArrayValue(type, value, path, callback) + ); + } + + private tryVisitPrimitiveValue( + type: string, + value: unknown, + path: string, + callback: (parsedValue: TypedDataValue) => void, + ): boolean { + // Basic type (address, bool, uint256, etc) + return ( + !this.isRecord(value) && + !Array.isArray(value) && + this.tryParsePrimitiveType(type) + .chain((primitiveType) => + encodeTypedDataValue(primitiveType, value).ifJust((encoded) => { + callback({ + path, + type, + value: new TypedDataValueField(encoded), + }); + }), + ) + .isJust() + ); + } + + private tryVisitStructValue( + type: string, + value: unknown, + path: string, + callback: (parsedValue: TypedDataValue) => void, + ): boolean { + const structType = this.structs[type]; + if (!structType || !this.isRecord(value)) { + return false; + } + for (const [fieldName, fieldType] of Object.entries(structType)) { + const fieldValue = value[fieldName]; + if (!fieldType || !fieldValue) { + return false; + } + const nextPath = path.length ? `${path}.${fieldName}` : fieldName; + if ( + !this.visitValue( + fieldType.typeName, + fieldValue, + `${nextPath}`, + callback, + ) + ) { + return false; + } + } + return true; + } + + private tryVisitArrayValue( + type: string, + value: unknown, + path: string, + callback: (parsedValue: TypedDataValue) => void, + ): boolean { + return ( + Array.isArray(value) && + this.tryParseArrayType(type) + .filter((t) => t.count.mapOrDefault((c) => value.length == c, true)) + .mapOrDefault((t) => { + callback({ + path: path, + type, + value: new TypedDataValueArray(value.length), + }); + for (const entry of value) { + const nextPath = path.length ? `${path}.[]` : "[]"; + if (!this.visitValue(t.rowType, entry, `${nextPath}`, callback)) { + return false; + } + } + return true; + }, false) + ); + } +} diff --git a/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParserService.ts b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParserService.ts index 1ae5978a8..1e96de124 100644 --- a/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParserService.ts +++ b/packages/signer/keyring-eth/src/internal/typed-data/service/TypedDataParserService.ts @@ -1,7 +1,8 @@ -import { injectable } from "inversify"; +import { Either } from "purify-ts"; -@injectable() -export class TypedDataParserService { - // Parse a typed data object into APDU commands - constructor() {} +import { TypedData } from "@api/model/TypedData"; +import { TypedDataValue } from "@internal/typed-data/model/Types"; + +export interface TypedDataParserService { + parse(message: TypedData): Either>; } diff --git a/packages/signer/keyring-eth/src/internal/typed-data/use-case/SignTypedDataUseCase.ts b/packages/signer/keyring-eth/src/internal/typed-data/use-case/SignTypedDataUseCase.ts index da851e1c7..dd06b35a2 100644 --- a/packages/signer/keyring-eth/src/internal/typed-data/use-case/SignTypedDataUseCase.ts +++ b/packages/signer/keyring-eth/src/internal/typed-data/use-case/SignTypedDataUseCase.ts @@ -5,7 +5,7 @@ import { TypedData } from "@api/model/TypedData"; import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; import { EthAppBinder } from "@internal/app-binder/EthAppBinder"; import { typedDataTypes } from "@internal/typed-data/di/typedDataTypes"; -import { TypedDataParserService } from "@internal/typed-data/service/TypedDataParserService"; +import { type TypedDataParserService } from "@internal/typed-data/service/TypedDataParserService"; @injectable() export class SignTypedDataUseCase { diff --git a/packages/signer/keyring-eth/tsconfig.cjs.json b/packages/signer/keyring-eth/tsconfig.cjs.json index a797cc717..3797ab02f 100644 --- a/packages/signer/keyring-eth/tsconfig.cjs.json +++ b/packages/signer/keyring-eth/tsconfig.cjs.json @@ -2,8 +2,8 @@ "extends": "./tsconfig.json", "exclude": ["src/**/*.test.ts", "jest.*.ts"], "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", + "module": "nodenext", + "moduleResolution": "nodenext", "outDir": "./lib/cjs", "declarationDir": "./lib/cjs" } diff --git a/packages/trusted-apps/scripts/build.mjs b/packages/trusted-apps/scripts/build.mjs index 0b920a623..d1d48a90a 100644 --- a/packages/trusted-apps/scripts/build.mjs +++ b/packages/trusted-apps/scripts/build.mjs @@ -19,4 +19,5 @@ const run = async () => await Promise.all([buildEsm(), builCjs()]); run().catch((e) => { console.error(e); + process.exitCode = e.exitCode; }); diff --git a/packages/trusted-apps/tsconfig.cjs.json b/packages/trusted-apps/tsconfig.cjs.json index a797cc717..3797ab02f 100644 --- a/packages/trusted-apps/tsconfig.cjs.json +++ b/packages/trusted-apps/tsconfig.cjs.json @@ -2,8 +2,8 @@ "extends": "./tsconfig.json", "exclude": ["src/**/*.test.ts", "jest.*.ts"], "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", + "module": "nodenext", + "moduleResolution": "nodenext", "outDir": "./lib/cjs", "declarationDir": "./lib/cjs" } diff --git a/packages/ui/tsconfig.cjs.json b/packages/ui/tsconfig.cjs.json index a797cc717..3797ab02f 100644 --- a/packages/ui/tsconfig.cjs.json +++ b/packages/ui/tsconfig.cjs.json @@ -2,8 +2,8 @@ "extends": "./tsconfig.json", "exclude": ["src/**/*.test.ts", "jest.*.ts"], "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", + "module": "nodenext", + "moduleResolution": "nodenext", "outDir": "./lib/cjs", "declarationDir": "./lib/cjs" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f75fa9a9..1c8cfbe6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^29.5.12 version: 29.5.12 '@types/node': - specifier: ^20.14.11 - version: 20.14.11 + specifier: ^22.4.0 + version: 22.4.0 concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -37,7 +37,7 @@ importers: version: 6.2.11 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + version: 29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)) prettier: specifier: ^3.3.2 version: 3.3.2 @@ -46,7 +46,7 @@ importers: version: 6.0.1 ts-jest: specifier: ^29.1.5 - version: 29.1.5(@babel/core@7.24.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.4))(jest@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)))(typescript@5.5.3) + version: 29.1.5(@babel/core@7.24.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.4))(jest@29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)))(typescript@5.5.3) tsc-alias: specifier: ^1.8.10 version: 1.8.10 @@ -57,8 +57,8 @@ importers: specifier: ^5.5.3 version: 5.5.3 zx: - specifier: ^8.1.2 - version: 8.1.2 + specifier: ^8.1.4 + version: 8.1.4 apps/sample: dependencies: @@ -69,11 +69,11 @@ importers: specifier: workspace:* version: link:../../packages/signer/keyring-eth '@ledgerhq/react-ui': - specifier: ^0.15.1 - version: 0.15.1(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)) + specifier: ^0.15.3 + version: 0.15.3(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)) '@sentry/nextjs': - specifier: ^8.13.0 - version: 8.13.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1)(next@14.2.4(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.92.1) + specifier: ^8.20.0 + version: 8.20.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(next@14.2.4(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.92.1) next: specifier: 14.2.4 version: 14.2.4(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -103,8 +103,8 @@ importers: specifier: ^5.1.25 version: 5.1.34 autoprefixer: - specifier: ^10.4.19 - version: 10.4.19(postcss@8.4.38) + specifier: ^10.4.20 + version: 10.4.20(postcss@8.4.38) globals: specifier: 15.8.0 version: 15.8.0 @@ -137,7 +137,7 @@ importers: devDependencies: ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.14.11)(typescript@5.5.3) + version: 10.9.2(@types/node@22.4.0)(typescript@5.5.3) packages/config/prettier: devDependencies: @@ -148,8 +148,8 @@ importers: packages/config/typescript: devDependencies: '@tsconfig/recommended': - specifier: ^1.0.6 - version: 1.0.6 + specifier: ^1.0.7 + version: 1.0.7 '@types/react': specifier: ^18.3.3 version: 18.3.3 @@ -162,6 +162,9 @@ importers: '@sentry/minimal': specifier: ^6.19.7 version: 6.19.7 + axios: + specifier: ^1.7.2 + version: 1.7.2 inversify: specifier: ^6.0.2 version: 6.0.2 @@ -213,16 +216,19 @@ importers: version: 1.0.6 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.14.11)(typescript@5.5.3) + version: 10.9.2(@types/node@22.4.0)(typescript@5.5.3) packages/signer/context-module: dependencies: axios: specifier: ^1.7.2 version: 1.7.2 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 ethers: - specifier: ^5.7.2 - version: 5.7.2 + specifier: ^6.13.2 + version: 6.13.2 inversify: specifier: ^6.0.2 version: 6.0.2 @@ -233,6 +239,9 @@ importers: specifier: ^0.2.2 version: 0.2.2 devDependencies: + '@ledgerhq/device-sdk-core': + specifier: workspace:* + version: link:../../core '@ledgerhq/eslint-config-dsdk': specifier: workspace:* version: link:../../config/eslint @@ -245,18 +254,21 @@ importers: '@ledgerhq/tsconfig-dsdk': specifier: workspace:* version: link:../../config/typescript + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.14.11)(typescript@5.5.3) + version: 10.9.2(@types/node@22.4.0)(typescript@5.5.3) packages/signer/keyring-eth: dependencies: ethers-v5: - specifier: npm:ethers@v5 + specifier: npm:ethers@^5.7.2 version: ethers@5.7.2 ethers-v6: - specifier: npm:ethers@v6 - version: ethers@6.13.1 + specifier: npm:ethers@^6.13.2 + version: ethers@6.13.2 inversify: specifier: ^6.0.2 version: 6.0.2 @@ -293,7 +305,7 @@ importers: version: 7.8.1 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.14.11)(typescript@5.5.3) + version: 10.9.2(@types/node@22.4.0)(typescript@5.5.3) packages/trusted-apps: dependencies: @@ -1585,8 +1597,8 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@ledgerhq/crypto-icons-ui@1.2.0': - resolution: {integrity: sha512-suazaJ7dTh2gMowH1NklxPpdgv2zbulcdWow9ASlnsHpHg9JGMT7y+G+9eEr1+aVX855rVFXgcp1YgpWvUVBwg==} + '@ledgerhq/crypto-icons-ui@1.3.0': + resolution: {integrity: sha512-m1n8iMIe+g5ZvkZ7VMVnqXTnRl7zxtj6RYRhehSshrNk6Lth8ZpOFthGVluge/2PYAdtc5FdImzmUHiz2fo7Wg==} peerDependencies: '@types/react': '*' react: '*' @@ -1597,8 +1609,8 @@ packages: '@types/react': optional: true - '@ledgerhq/icons-ui@0.7.0': - resolution: {integrity: sha512-4Jn1sAuCKGG2CLmPm2W9hn/xcBoZS8Hzw4c/cJAzUq8QfRjCSEmPyk73PPCgHIQjc2AkO2qEw1zFdEfWeXocFw==} + '@ledgerhq/icons-ui@0.7.1': + resolution: {integrity: sha512-VTfyz8X4GN+ZwS+7BTCJkeSpNMS6ob8XvhUS/J7zRtKAGj7z0Yy1nMuWgYw8rL4NfUYpZnYbShtVVjsEK2uylA==} peerDependencies: '@types/react': '*' react: '*' @@ -1609,8 +1621,8 @@ packages: '@types/react': optional: true - '@ledgerhq/react-ui@0.15.1': - resolution: {integrity: sha512-b58pgKwGnR/WWfpymwRqdjuTlazSOC1dMgQSZKOL4HkYggj0FoXC8PECaC8cICS2ygSJG6mlw1RuZK+IAnCrIg==} + '@ledgerhq/react-ui@0.15.3': + resolution: {integrity: sha512-12h8+PGAevUf+1Xs7vuIjl7JTn+eQPS1yoZS+t26/hnCB0DmgdtukfXEVWcyMRvLD7nLnJWB2bX3qSZ4wjCTVA==} peerDependencies: '@types/react': '*' react: '>=17.0.2' @@ -1771,32 +1783,32 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/instrumentation-connect@0.37.0': - resolution: {integrity: sha512-SeQktDIH5rNzjiEiazWiJAIXkmnLOnNV7wwHpahrqE0Ph+Z3heqMfxRtoMtbdJSIYLfcNZYO51AjxZ00IXufdw==} + '@opentelemetry/instrumentation-connect@0.38.0': + resolution: {integrity: sha512-2/nRnx3pjYEmdPIaBwtgtSviTKHWnDZN3R+TkRUnhIVrvBKVcq+I5B2rtd6mr6Fe9cHlZ9Ojcuh7pkNh/xdWWg==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-express@0.40.1': - resolution: {integrity: sha512-+RKMvVe2zw3kIXRup9c1jFu3T4d0fs5aKy015TpiMyoCKX1UMu3Z0lfgYtuyiSTANvg5hZnDbWmQmqSPj9VTvg==} + '@opentelemetry/instrumentation-express@0.41.0': + resolution: {integrity: sha512-/B7fbMdaf3SYe5f1P973tkqd6s7XZirjpfkoJ63E7nltU30qmlgm9tY5XwZOzAFI0rHS9tbrFI2HFPAvQUFe/A==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-fastify@0.37.0': - resolution: {integrity: sha512-WRjwzNZgupSzbEYvo9s+QuHJRqZJjVdNxSEpGBwWK8RKLlHGwGVAu0gcc2gPamJWUJsGqPGvahAPWM18ZkWj6A==} + '@opentelemetry/instrumentation-fastify@0.38.0': + resolution: {integrity: sha512-HBVLpTSYpkQZ87/Df3N0gAw7VzYZV3n28THIBrJWfuqw3Or7UqdhnjeuMIPQ04BKk3aZc0cWn2naSQObbh5vXw==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-graphql@0.41.0': - resolution: {integrity: sha512-R/gXeljgIhaRDKquVkKYT5QHPnFouM8ooyePZEP0kqyaVAedtR1V7NfAUJbxfTG5fBQa5wdmLjvu63+tzRXZCA==} + '@opentelemetry/instrumentation-graphql@0.42.0': + resolution: {integrity: sha512-N8SOwoKL9KQSX7z3gOaw5UaTeVQcfDO1c21csVHnmnmGUoqsXbArK2B8VuwPWcv6/BC/i3io+xTo7QGRZ/z28Q==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-hapi@0.39.0': - resolution: {integrity: sha512-ik2nA9Yj2s2ay+aNY+tJsKCsEx6Tsc2g/MK0iWBW5tibwrWKTy1pdVt5sB3kd5Gkimqj23UV5+FH2JFcQLeKug==} + '@opentelemetry/instrumentation-hapi@0.40.0': + resolution: {integrity: sha512-8U/w7Ifumtd2bSN1OLaSwAAFhb9FyqWUki3lMMB0ds+1+HdSxYBe9aspEJEgvxAqOkrQnVniAPTEGf1pGM7SOw==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -1807,62 +1819,62 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-ioredis@0.41.0': - resolution: {integrity: sha512-rxiLloU8VyeJGm5j2fZS8ShVdB82n7VNP8wTwfUQqDwRfHCnkzGr+buKoxuhGD91gtwJ91RHkjHA1Eg6RqsUTg==} + '@opentelemetry/instrumentation-ioredis@0.42.0': + resolution: {integrity: sha512-P11H168EKvBB9TUSasNDOGJCSkpT44XgoM6d3gRIWAa9ghLpYhl0uRkS8//MqPzcJVHr3h3RmfXIpiYLjyIZTw==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-koa@0.41.0': - resolution: {integrity: sha512-mbPnDt7ELvpM2S0vixYUsde7122lgegLOJQxx8iJQbB8YHal/xnTh9v7IfArSVzIDo+E+080hxZyUZD4boOWkw==} + '@opentelemetry/instrumentation-koa@0.42.0': + resolution: {integrity: sha512-H1BEmnMhho8o8HuNRq5zEI4+SIHDIglNB7BPKohZyWG4fWNuR7yM4GTlR01Syq21vODAS7z5omblScJD/eZdKw==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-mongodb@0.45.0': - resolution: {integrity: sha512-xnZP9+ayeB1JJyNE9cIiwhOJTzNEsRhXVdLgfzmrs48Chhhk026mQdM5CITfyXSCfN73FGAIB8d91+pflJEfWQ==} + '@opentelemetry/instrumentation-mongodb@0.46.0': + resolution: {integrity: sha512-VF/MicZ5UOBiXrqBslzwxhN7TVqzu1/LN/QDpkskqM0Zm0aZ4CVRbUygL8d7lrjLn15x5kGIe8VsSphMfPJzlA==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-mongoose@0.39.0': - resolution: {integrity: sha512-J1r66A7zJklPPhMtrFOO7/Ud2p0Pv5u8+r23Cd1JUH6fYPmftNJVsLp2urAt6PHK4jVqpP/YegN8wzjJ2mZNPQ==} + '@opentelemetry/instrumentation-mongoose@0.40.0': + resolution: {integrity: sha512-niRi5ZUnkgzRhIGMOozTyoZIvJKNJyhijQI4nF4iFSb+FUx2v5fngfR+8XLmdQAO7xmsD8E5vEGdDVYVtKbZew==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-mysql2@0.39.0': - resolution: {integrity: sha512-Iypuq2z6TCfriAXCIZjRq8GTFCKhQv5SpXbmI+e60rYdXw8NHtMH4NXcGF0eKTuoCsC59IYSTUvDQYDKReaszA==} + '@opentelemetry/instrumentation-mysql2@0.40.0': + resolution: {integrity: sha512-0xfS1xcqUmY7WE1uWjlmI67Xg3QsSUlNT+AcXHeA4BDUPwZtWqF4ezIwLgpVZfHOnkAEheqGfNSWd1PIu3Wnfg==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-mysql@0.39.0': - resolution: {integrity: sha512-8snHPh83rhrDf31v9Kq0Nf+ts8hdr7NguuszRqZomZBHgE0+UyXZSkXHAAFZoBPPRMGyM68uaFE5hVtFl+wOcA==} + '@opentelemetry/instrumentation-mysql@0.40.0': + resolution: {integrity: sha512-d7ja8yizsOCNMYIJt5PH/fKZXjb/mS48zLROO4BzZTtDfhNCl2UM/9VIomP2qkGIFVouSJrGr/T00EzY7bPtKA==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-nestjs-core@0.38.0': - resolution: {integrity: sha512-M381Df1dM8aqihZz2yK+ugvMFK5vlHG/835dc67Sx2hH4pQEQYDA2PpFPTgc9AYYOydQaj7ClFQunESimjXDgg==} + '@opentelemetry/instrumentation-nestjs-core@0.39.0': + resolution: {integrity: sha512-mewVhEXdikyvIZoMIUry8eb8l3HUjuQjSjVbmLVTt4NQi35tkpnHQrG9bTRBrl3403LoWZ2njMPJyg4l6HfKvA==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-pg@0.42.0': - resolution: {integrity: sha512-sjgcM8CswYy8zxHgXv4RAZ09DlYhQ+9TdlourUs63Df/ek5RrB1ZbjznqW7PB6c3TyJJmX6AVtPTjAsROovEjA==} + '@opentelemetry/instrumentation-pg@0.43.0': + resolution: {integrity: sha512-og23KLyoxdnAeFs1UWqzSonuCkePUzCX30keSYigIzJe/6WSYA8rnEI5lobcxPEzg+GcU06J7jzokuEHbjVJNw==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-redis-4@0.40.0': - resolution: {integrity: sha512-0ieQYJb6yl35kXA75LQUPhHtGjtQU9L85KlWa7d4ohBbk/iQKZ3X3CFl5jC5vNMq/GGPB3+w3IxNvALlHtrp7A==} + '@opentelemetry/instrumentation-redis-4@0.41.0': + resolution: {integrity: sha512-H7IfGTqW2reLXqput4yzAe8YpDC0fmVNal95GHMLOrS89W+qWUKIqxolSh63hJyfmwPSFwXASzj7wpSk8Az+Dg==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.43.0': - resolution: {integrity: sha512-S1uHE+sxaepgp+t8lvIDuRgyjJWisAb733198kwQTUc9ZtYQ2V2gmyCtR1x21ePGVLoMiX/NWY7WA290hwkjJQ==} + '@opentelemetry/instrumentation@0.46.0': + resolution: {integrity: sha512-a9TijXZZbk0vI5TGLZl+0kxyFfrXHhX6Svtz7Pp2/VBlCSKrazuULEyoJQrOknJyFWNMEmbbJgOciHCCpQcisw==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -1928,8 +1940,8 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@prisma/instrumentation@5.16.0': - resolution: {integrity: sha512-MVzNRW2ikWvVNnMIEgQMcwWxpFD+XF2U2h0Qz7MjutRqJxrhWexWV2aSi2OXRaU8UL5wzWw7pnjdKUzYhWauLg==} + '@prisma/instrumentation@5.17.0': + resolution: {integrity: sha512-c1Sle4ji8aasMcYfBBHFM56We4ljfenVtRmS8aY06BllS7SoU6SmJBwG7vil+GHiR0Yrh+t9iBwt4AY0Jr4KNQ==} '@react-native-community/cli-clean@13.6.9': resolution: {integrity: sha512-7Dj5+4p9JggxuVNOjPbduZBAP1SUgNhLKVw5noBUzT/3ZpUZkDM+RCSwyoyg8xKWoE4OrdUAXwAFlMcFDPKykA==} @@ -2051,28 +2063,28 @@ packages: rollup: optional: true - '@sentry-internal/browser-utils@8.13.0': - resolution: {integrity: sha512-lqq8BYbbs9KTlDuyB5NjdZB6P/llqQs32KUgaCQ/k5DFB4Zf56+BFHXObnMHxwx375X1uixtnEphagWZa+nsLQ==} + '@sentry-internal/browser-utils@8.20.0': + resolution: {integrity: sha512-GGYNiELnT4ByidHyS4/M8UF8Oxagm5R13QyTncQGq8nZcQhcFZ9mdxLnf1/R4+j44Fph2Cgzafe8jGP/AMA9zw==} engines: {node: '>=14.18'} - '@sentry-internal/feedback@8.13.0': - resolution: {integrity: sha512-YyJ6SzpTonixvguAg0H9vkEp7Jq8ZeVY8M4n47ClR0+TtaAUp04ZhcJpHKF7PwBIAzc7DRr2XP112tmWgiVEcg==} + '@sentry-internal/feedback@8.20.0': + resolution: {integrity: sha512-mFvAoVpVShkDB2AgEr/dE96NSTPKI/lGMBznZMg7ZEcwZhLfH7HvLYCadIskRfzqFTLOUpbm9ciIO4SyR/4bDA==} engines: {node: '>=14.18'} - '@sentry-internal/replay-canvas@8.13.0': - resolution: {integrity: sha512-lPlfWVIHX+gW4S8a/UOVutuqMyQhlkNUAay0W21MVhZJT5Mtj0p21D/Cz7nrOQRDIiLNq90KAGK2tLxx5NkiWA==} + '@sentry-internal/replay-canvas@8.20.0': + resolution: {integrity: sha512-LXV/pMH9KMw6CtImenMsiBkYIFIc97pDJ/rC7mVImKIROQ45fxGp/JBXM4Id0GENyA2+SySMWVQCAAapSfHZTw==} engines: {node: '>=14.18'} - '@sentry-internal/replay@8.13.0': - resolution: {integrity: sha512-DJ1jF/Pab0FH4SeCvSGCnGAu/s0wJvhBWM5VjQp7Jjmcfunp+R3vJibqU8gAVZU1nYRLaqprLdIXrSyP2Km8nQ==} + '@sentry-internal/replay@8.20.0': + resolution: {integrity: sha512-sCiI7SOAHq5XsxkixtoMofeSyKd/hVgDV+4145f6nN9m7nLzig4PBQwh2SgK2piJ2mfaXfqcdzA1pShPYldaJA==} engines: {node: '>=14.18'} '@sentry/babel-plugin-component-annotate@2.20.1': resolution: {integrity: sha512-4mhEwYTK00bIb5Y9UWIELVUfru587Vaeg0DQGswv4aIRHIiMKLyNqCEejaaybQ/fNChIZOKmvyqXk430YVd7Qg==} engines: {node: '>= 14'} - '@sentry/browser@8.13.0': - resolution: {integrity: sha512-/tp7HZ5qjwDLtwooPMoexdAi2PG7gMNY0bHeMlwy20hs8mclC8RW8ZiJA6czXHfgnbmvxfrHaY53IJyz//JnlA==} + '@sentry/browser@8.20.0': + resolution: {integrity: sha512-JDZbCreY44/fHYN28QzsAwEHXa2rc1hzM6GE4RSlXCdAhNfrjVxyYDxhw/50pVEHZg1WXxf7ZmERjocV5VJHsw==} engines: {node: '>=14.18'} '@sentry/bundler-plugin-core@2.20.1': @@ -2125,8 +2137,8 @@ packages: engines: {node: '>= 10'} hasBin: true - '@sentry/core@8.13.0': - resolution: {integrity: sha512-N9Qg4ZGxZWp8eb2eUUHVVKgjBLtFIjS805nG92s6yJmkvOpKm6mLtcUaT/iDf3Hta6nG+xRkhbE3r+Z4cbXG8w==} + '@sentry/core@8.20.0': + resolution: {integrity: sha512-R81snuw+67VT4aCxr6ShST/s0Y6FlwN2YczhDwaGyzumn5rlvA6A4JtQDeExduNoDDyv4T3LrmW8wlYZn3CJJw==} engines: {node: '>=14.18'} '@sentry/hub@6.19.7': @@ -2137,8 +2149,8 @@ packages: resolution: {integrity: sha512-wcYmSJOdvk6VAPx8IcmZgN08XTXRwRtB1aOLZm+MVHjIZIhHoBGZJYTVQS/BWjldsamj2cX3YGbGXNunaCfYJQ==} engines: {node: '>=6'} - '@sentry/nextjs@8.13.0': - resolution: {integrity: sha512-zXZWCA/sfGVP3MEGrshUZiMM5eOu33o8vDTKExsmGRWGTsR1tkLyLUwxQQSE9PdihUnPqv/Nw27eMXZv2XpJMw==} + '@sentry/nextjs@8.20.0': + resolution: {integrity: sha512-ZMi50qeklxibnNehlghNvlmzz1NIvYUGglDMy/m/N67SfXiq5PXyVziJAoCKQXR7nrvoQx0Mx17Z9ZFIwgjSJQ==} engines: {node: '>=14.18'} peerDependencies: next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0 @@ -2147,12 +2159,12 @@ packages: webpack: optional: true - '@sentry/node@8.13.0': - resolution: {integrity: sha512-OeZ7K90RhyxfwfreerIi4cszzHrPRRH36STJno2+p3sIGbG5VScOccqXzYEOAqHpByxnti4KQN34BLAT2BFOEA==} + '@sentry/node@8.20.0': + resolution: {integrity: sha512-i4ywT2m0Gw65U3uwI4NwiNcyqp9YF6/RsusfH1pg4YkiL/RYp7FS0MPVgMggfvoue9S3KjCgRVlzTLwFATyPXQ==} engines: {node: '>=14.18'} - '@sentry/opentelemetry@8.13.0': - resolution: {integrity: sha512-NYn/HNE/SxFXe8pfnxJknhrrRzYRMHNssCoi5M1CeR5G7F2BGxxVmaGsd8j0WyTCpUS4i97G4vhYtDGxHvWN6w==} + '@sentry/opentelemetry@8.20.0': + resolution: {integrity: sha512-NFcLK6+t9wUc4HlGKeuDn6W4KjZxZfZmWlrK2/tgC5KzG1cnVeOnWUrJzGHTa+YDDdIijpjiFUcpXGPkX3rmIg==} engines: {node: '>=14.18'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -2161,8 +2173,8 @@ packages: '@opentelemetry/sdk-trace-base': ^1.25.1 '@opentelemetry/semantic-conventions': ^1.25.1 - '@sentry/react@8.13.0': - resolution: {integrity: sha512-gz+aHZMcl6uvHkmLBGzMGjJJ+Vpl+W0VXJsKB9fdjZDDF5vJpgXTR9mwMEXJ9lKi+cY6tDe0+af+DA8BGJgw0Q==} + '@sentry/react@8.20.0': + resolution: {integrity: sha512-vqA0o9ysdfA24/ADhsJwsmCNdUWRu2ycmVN1Sr76v+ZggyOCFzE7XD13kbqk1G3jPb8nptNu/6Zwpcy5pP4mtw==} engines: {node: '>=14.18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x @@ -2171,20 +2183,20 @@ packages: resolution: {integrity: sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg==} engines: {node: '>=6'} - '@sentry/types@8.13.0': - resolution: {integrity: sha512-r63s/H5gvQnQM9tTGBXz2xErUbxZALh4e2Lg/1aHj4zIvGLBjA2z5qWsh6TEZYbpmgAyGShLDr6+rWeUVf9yBQ==} + '@sentry/types@8.20.0': + resolution: {integrity: sha512-6IP278KojOpiAA7vrd1hjhUyn26cl0n0nGsShzic5ztCVs92sTeVRnh7MTB9irDVtAbOEyt/YH6go3h+Jia1pA==} engines: {node: '>=14.18'} '@sentry/utils@6.19.7': resolution: {integrity: sha512-z95ECmE3i9pbWoXQrD/7PgkBAzJYR+iXtPuTkpBjDKs86O3mT+PXOT3BAn79w2wkn7/i3vOGD2xVr1uiMl26dA==} engines: {node: '>=6'} - '@sentry/utils@8.13.0': - resolution: {integrity: sha512-PxV0v9VbGWH9zP37P5w2msLUFDr287nYjoY2XVF+RSolyiTs1CQNI5ZMUO3o4MsSac/dpXxjyrZXQd72t/jRYA==} + '@sentry/utils@8.20.0': + resolution: {integrity: sha512-+1I5H8dojURiEUGPliDwheQk8dhjp8uV1sMccR/W/zjFrt4wZyPs+Ttp/V7gzm9LDJoNek9tmELert/jQqWTgg==} engines: {node: '>=14.18'} - '@sentry/vercel-edge@8.13.0': - resolution: {integrity: sha512-6i2FTpIec/o+lfdtzXRIebo38ca4DWHXmpzfFZJVcYdBFWbu2F3Q6c61tnXGuZhyRjGtEW1ASPlbd8nterQIwQ==} + '@sentry/vercel-edge@8.20.0': + resolution: {integrity: sha512-4UiK72M9mf3++YapeIdwUcF0d1uzWfgYm8fx3YgEz6bQUdrts3Jg4e+dbvpv57uUAiTnNN3JKZmkT1ep9ZonKw==} engines: {node: '>=14.18'} '@sentry/webpack-plugin@2.20.1': @@ -2294,11 +2306,8 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tsconfig/recommended@1.0.6': - resolution: {integrity: sha512-0IKu9GHYF1NGTJiYgfWwqnOQSlnE9V9R7YohHNNf0/fj/SyOZWzdd06JFr0fLpg1Mqw0kGbYg8w5xdkSqLKM9g==} - - '@types/accepts@1.3.7': - resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} + '@tsconfig/recommended@1.0.7': + resolution: {integrity: sha512-xiNMgCuoy4mCL4JTywk9XFs5xpRUcKxtWEcMR6FNMtsgewYTIgIR+nvlP4A4iRCAzRsHMnPhvTRrzp4AGcRTEA==} '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2312,20 +2321,11 @@ packages: '@types/babel__traverse@7.20.4': resolution: {integrity: sha512-mSM/iKUk5fDDrEV/e83qY+Cr3I1+Q3qqTuEn++HAWYjEa1+NxZr6CNrcJGf2ZTnq4HoFGC3zaTPZTobCzCFukA==} - '@types/body-parser@1.19.5': - resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} - '@types/connect@3.4.36': resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - - '@types/content-disposition@0.5.8': - resolution: {integrity: sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==} - - '@types/cookies@0.9.0': - resolution: {integrity: sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==} + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2336,12 +2336,6 @@ packages: '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - '@types/express-serve-static-core@4.19.5': - resolution: {integrity: sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==} - - '@types/express@4.17.21': - resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} - '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} @@ -2351,15 +2345,9 @@ packages: '@types/hoist-non-react-statics@3.3.5': resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==} - '@types/http-assert@1.5.5': - resolution: {integrity: sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==} - '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} - '@types/http-errors@2.0.4': - resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} - '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2378,21 +2366,6 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - '@types/keygrip@1.0.6': - resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} - - '@types/koa-compose@3.2.8': - resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==} - - '@types/koa@2.14.0': - resolution: {integrity: sha512-DTDUyznHGNHAl+wd1n0z1jxNajduyTh8R53xoewuerdBzGo6Ogj6F2299BFtrexJw4NtgjsI5SMPCmV9gZwGXA==} - - '@types/koa__router@12.0.3': - resolution: {integrity: sha512-5YUJVv6NwM1z7m6FuYpKfNLTZ932Z6EF6xy2BbtpJSyn13DKNQEkXVffFVSnJHxvwwWh2SAeumpjAYUELqgjyw==} - - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/mysql@2.15.22': resolution: {integrity: sha512-wK1pzsJVVAjYCSZWQoWHziQZbNggXFDUEIGf54g4ZM/ERuP86uGdWeKZWMYlqTPMZfHJJvLPyogXGvCOg87yLQ==} @@ -2414,6 +2387,9 @@ packages: '@types/node@20.14.11': resolution: {integrity: sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==} + '@types/node@22.4.0': + resolution: {integrity: sha512-49AbMDwYUz7EXxKU/r7mXOsxwFr4BYbvB7tWYxVuLdb2ibd30ijjXINSMAHiEEZk5PCRBmW1gUeisn2VMKt3cQ==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -2426,12 +2402,6 @@ packages: '@types/prop-types@15.7.12': resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} - '@types/qs@6.9.15': - resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@18.3.0': resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} @@ -2444,12 +2414,6 @@ packages: '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} - '@types/send@0.17.4': - resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} - - '@types/serve-static@1.15.7': - resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} - '@types/shimmer@1.0.5': resolution: {integrity: sha512-9Hp0ObzwwO57DpLFF0InUjUm/II8GmKAvzbefxQTihCb7KI6yc9yzf0nLc4mVdby5N4DRCgQM2wCup9KTieeww==} @@ -2752,8 +2716,8 @@ packages: atomically@2.0.2: resolution: {integrity: sha512-Xfmb4q5QV7uqTlVdMSTtO5eF4DCHfNOdaPyKlbFShkzeNP+3lj3yjjcbdjSmEY4+pDBKJ9g26aP+ImTe88UHoQ==} - autoprefixer@10.4.19: - resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==} + autoprefixer@10.4.20: + resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -2872,13 +2836,8 @@ packages: brorand@1.1.0: resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} - browserslist@4.23.0: - resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - browserslist@4.23.1: - resolution: {integrity: sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==} + browserslist@4.23.3: + resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2952,12 +2911,12 @@ packages: camelize@1.0.1: resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} - caniuse-lite@1.0.30001607: - resolution: {integrity: sha512-WcvhVRjXLKFB/kmOFVwELtMxyhq3iM/MvmXcyCe2PNf166c39mptscOc/45TTS96n2gpNV2z7+NakArTWZCQ3w==} - caniuse-lite@1.0.30001633: resolution: {integrity: sha512-6sT0yf/z5jqf8tISAgpJDrmwOpLsrpnyCdD/lOZKvKkkJK4Dn0X5i7KF7THEZhOq+30bmhwBlNEaqPUiHiKtZg==} + caniuse-lite@1.0.30001647: + resolution: {integrity: sha512-n83xdNiyeNcHpzWY+1aFbqCK7LuLfBricc4+alSQL2Xb6OR3XpnQAmlDG+pQcdTfiHRuLcQ96VOfrPSGiNJYSg==} + chalk@2.3.0: resolution: {integrity: sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==} engines: {node: '>=4'} @@ -3176,6 +3135,9 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + crypto-random-string@4.0.0: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} @@ -3388,11 +3350,8 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-to-chromium@1.4.729: - resolution: {integrity: sha512-bx7+5Saea/qu14kmPTDHQxkp2UnziG3iajUQu3BxFvCOnpAJdDbMV4rSl+EqFDkkpNNVUFlR1kDfpL59xfy1HA==} - - electron-to-chromium@1.4.801: - resolution: {integrity: sha512-PnlUz15ii38MZMD2/CEsAzyee8tv9vFntX5nhtd2/4tv4HqY7C5q2faUAjmkXS/UFpVooJ/5H6kayRKYWoGMXQ==} + electron-to-chromium@1.5.4: + resolution: {integrity: sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==} elliptic@6.5.4: resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==} @@ -3586,8 +3545,8 @@ packages: ethers@5.7.2: resolution: {integrity: sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==} - ethers@6.13.1: - resolution: {integrity: sha512-hdJ2HOxg/xx97Lm9HdCWk949BfYqYWpyw4//78SiwOLgASyfrNszfMUNB2joKjvGUdwhHfaiMMFFwacVVoLR9A==} + ethers@6.13.2: + resolution: {integrity: sha512-9VkriTTed+/27BGuY1s0hf441kqwHJ1wtN2edksEtiRvXx+soxRX3iSXTfFqq2+YwrOqbDoTHjIhQnjJRlzKmg==} engines: {node: '>=14.0.0'} event-target-shim@5.0.1: @@ -4040,11 +3999,11 @@ packages: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} - import-in-the-middle@1.4.2: - resolution: {integrity: sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw==} + import-in-the-middle@1.10.0: + resolution: {integrity: sha512-Z1jumVdF2GwnnYfM0a/y2ts7mZbwFMgt5rRuVmLgobgahC6iKgN5MBuXjzfTIOUpq5LSU10vJIPpVKe0X89fIw==} - import-in-the-middle@1.8.1: - resolution: {integrity: sha512-yhRwoHtiLGvmSozNOALgjRPFI6uYsds60EoMqqnXyyv+JOIW/BrrLejuTGBt+bq0T5tLzOHrN0T7xYTm4Qt/ng==} + import-in-the-middle@1.7.1: + resolution: {integrity: sha512-1LrZPDtW+atAxH42S6288qyDFNQ2YCty+2mxEPRtfazH6Z5QwkaBSTS2ods7hnVJioF6rkRfNoA6A/MstpFXLg==} import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} @@ -4945,8 +4904,8 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.14: - resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} node-stream-zip@1.15.0: resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} @@ -5020,9 +4979,11 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} - opentelemetry-instrumentation-fetch-node@1.2.0: - resolution: {integrity: sha512-aiSt/4ubOTyb1N5C2ZbGrBvaJOXIZhZvpRPYuUVxQJe27wJZqf/o65iPrqgLcgfeOLaQ8cS2Q+762jrYvniTrA==} + opentelemetry-instrumentation-fetch-node@1.2.3: + resolution: {integrity: sha512-Qb11T7KvoCevMaSeuamcLsAD+pZnavkhDnlVL0kRozfhl42dKG5Q3anUklAFKJZjY3twLR+BnRa6DlwwkIE/+A==} engines: {node: '>18.0.0'} + peerDependencies: + '@opentelemetry/api': ^1.6.0 optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -6161,6 +6122,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.6: + resolution: {integrity: sha512-e/vggGopEfTKSvj4ihnOLTsqhrKRN3LeO6qSN/GxohhuRv8qH9bNQ4B8W7e/vFL+0XTnmHPB4/kegunZGA4Org==} + unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} @@ -6199,14 +6163,8 @@ packages: unplugin@1.0.1: resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} - update-browserslist-db@1.0.13: - resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - update-browserslist-db@1.0.16: - resolution: {integrity: sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==} + update-browserslist-db@1.1.0: + resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -6465,8 +6423,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zx@8.1.2: - resolution: {integrity: sha512-zkCiXKh8D/eo6r58OmJvO5mc2NthcSRvysb3fuS6VQlHPbEPBcxduRwM3m6ZfHj+7cLHcrahCnuO2TDAbW+6xw==} + zx@8.1.4: + resolution: {integrity: sha512-QFDYYpnzdpRiJ3dL2102Cw26FpXpWshW4QLTGxiYfIcwdAqg084jRCkK/kuP/NOSkxOjydRwNFG81qzA5r1a6w==} engines: {node: '>= 12.17.0'} hasBin: true @@ -6566,7 +6524,7 @@ snapshots: dependencies: '@babel/compat-data': 7.23.5 '@babel/helper-validator-option': 7.23.5 - browserslist: 4.23.0 + browserslist: 4.23.3 lru-cache: 5.1.1 semver: 6.3.1 @@ -6574,7 +6532,7 @@ snapshots: dependencies: '@babel/compat-data': 7.24.7 '@babel/helper-validator-option': 7.24.7 - browserslist: 4.23.1 + browserslist: 4.23.3 lru-cache: 5.1.1 semver: 6.3.1 @@ -8246,7 +8204,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3))': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -8260,7 +8218,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + jest-config: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -8438,7 +8396,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 - '@ledgerhq/crypto-icons-ui@1.2.0(@types/react@18.3.3)(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))(styled-system@5.1.5)': + '@ledgerhq/crypto-icons-ui@1.3.0(@types/react@18.3.3)(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))(styled-system@5.1.5)': dependencies: react: 18.3.1 react-native-svg: 15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1) @@ -8447,7 +8405,7 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 - '@ledgerhq/icons-ui@0.7.0(@types/react@18.3.3)(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))(styled-system@5.1.5)': + '@ledgerhq/icons-ui@0.7.1(@types/react@18.3.3)(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))(styled-system@5.1.5)': dependencies: react: 18.3.1 react-native-svg: 15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1) @@ -8456,11 +8414,11 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 - '@ledgerhq/react-ui@0.15.1(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))': + '@ledgerhq/react-ui@0.15.3(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))': dependencies: '@floating-ui/react-dom': 0.4.3(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@ledgerhq/crypto-icons-ui': 1.2.0(@types/react@18.3.3)(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))(styled-system@5.1.5) - '@ledgerhq/icons-ui': 0.7.0(@types/react@18.3.3)(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))(styled-system@5.1.5) + '@ledgerhq/crypto-icons-ui': 1.3.0(@types/react@18.3.3)(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))(styled-system@5.1.5) + '@ledgerhq/icons-ui': 0.7.1(@types/react@18.3.3)(react-native-svg@15.3.0(react-native@0.74.3(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.3.3)(react@18.3.1))(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1))(styled-system@5.1.5) '@ledgerhq/ui-shared': 0.2.1 '@tippyjs/react': 4.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) color: 4.2.3 @@ -8641,7 +8599,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.25.1 - '@opentelemetry/instrumentation-connect@0.37.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-connect@0.38.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) @@ -8651,7 +8609,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-express@0.40.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-express@0.41.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) @@ -8660,7 +8618,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-fastify@0.37.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-fastify@0.38.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) @@ -8669,14 +8627,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-graphql@0.41.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-graphql@0.42.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-hapi@0.39.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-hapi@0.40.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) @@ -8695,7 +8653,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-ioredis@0.41.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-ioredis@0.42.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) @@ -8704,18 +8662,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-koa@0.41.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-koa@0.42.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.25.1 - '@types/koa': 2.14.0 - '@types/koa__router': 12.0.3 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mongodb@0.45.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mongodb@0.46.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) @@ -8724,7 +8680,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mongoose@0.39.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mongoose@0.40.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) @@ -8733,7 +8689,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mysql2@0.39.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mysql2@0.40.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) @@ -8742,7 +8698,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mysql@0.39.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mysql@0.40.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) @@ -8751,7 +8707,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-nestjs-core@0.38.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-nestjs-core@0.39.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) @@ -8759,7 +8715,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-pg@0.42.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-pg@0.43.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) @@ -8770,7 +8726,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-redis-4@0.40.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-redis-4@0.41.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) @@ -8779,11 +8735,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.43.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.46.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@types/shimmer': 1.0.5 - import-in-the-middle: 1.4.2 + import-in-the-middle: 1.7.1 require-in-the-middle: 7.3.0 semver: 7.6.3 shimmer: 1.2.1 @@ -8796,7 +8752,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.52.1 '@types/shimmer': 1.0.5 - import-in-the-middle: 1.8.1 + import-in-the-middle: 1.10.0 require-in-the-middle: 7.3.0 semver: 7.6.3 shimmer: 1.2.1 @@ -8851,7 +8807,7 @@ snapshots: '@popperjs/core@2.11.8': {} - '@prisma/instrumentation@5.16.0': + '@prisma/instrumentation@5.17.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) @@ -9177,43 +9133,43 @@ snapshots: optionalDependencies: rollup: 3.29.4 - '@sentry-internal/browser-utils@8.13.0': + '@sentry-internal/browser-utils@8.20.0': dependencies: - '@sentry/core': 8.13.0 - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@sentry/core': 8.20.0 + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 - '@sentry-internal/feedback@8.13.0': + '@sentry-internal/feedback@8.20.0': dependencies: - '@sentry/core': 8.13.0 - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@sentry/core': 8.20.0 + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 - '@sentry-internal/replay-canvas@8.13.0': + '@sentry-internal/replay-canvas@8.20.0': dependencies: - '@sentry-internal/replay': 8.13.0 - '@sentry/core': 8.13.0 - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@sentry-internal/replay': 8.20.0 + '@sentry/core': 8.20.0 + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 - '@sentry-internal/replay@8.13.0': + '@sentry-internal/replay@8.20.0': dependencies: - '@sentry-internal/browser-utils': 8.13.0 - '@sentry/core': 8.13.0 - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@sentry-internal/browser-utils': 8.20.0 + '@sentry/core': 8.20.0 + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 '@sentry/babel-plugin-component-annotate@2.20.1': {} - '@sentry/browser@8.13.0': + '@sentry/browser@8.20.0': dependencies: - '@sentry-internal/browser-utils': 8.13.0 - '@sentry-internal/feedback': 8.13.0 - '@sentry-internal/replay': 8.13.0 - '@sentry-internal/replay-canvas': 8.13.0 - '@sentry/core': 8.13.0 - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@sentry-internal/browser-utils': 8.20.0 + '@sentry-internal/feedback': 8.20.0 + '@sentry-internal/replay': 8.20.0 + '@sentry-internal/replay-canvas': 8.20.0 + '@sentry/core': 8.20.0 + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 '@sentry/bundler-plugin-core@2.20.1': dependencies: @@ -9269,10 +9225,10 @@ snapshots: - encoding - supports-color - '@sentry/core@8.13.0': + '@sentry/core@8.20.0': dependencies: - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 '@sentry/hub@6.19.7': dependencies: @@ -9286,17 +9242,18 @@ snapshots: '@sentry/types': 6.19.7 tslib: 1.14.1 - '@sentry/nextjs@8.13.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1)(next@14.2.4(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.92.1)': + '@sentry/nextjs@8.20.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(next@14.2.4(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.92.1)': dependencies: '@opentelemetry/instrumentation-http': 0.52.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.25.1 '@rollup/plugin-commonjs': 26.0.1(rollup@3.29.4) - '@sentry/core': 8.13.0 - '@sentry/node': 8.13.0 - '@sentry/opentelemetry': 8.13.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1) - '@sentry/react': 8.13.0(react@18.3.1) - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 - '@sentry/vercel-edge': 8.13.0 + '@sentry/core': 8.20.0 + '@sentry/node': 8.20.0 + '@sentry/opentelemetry': 8.20.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1) + '@sentry/react': 8.20.0(react@18.3.1) + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 + '@sentry/vercel-edge': 8.20.0 '@sentry/webpack-plugin': 2.20.1(webpack@5.92.1) chalk: 3.0.0 next: 14.2.4(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -9310,83 +9267,83 @@ snapshots: - '@opentelemetry/core' - '@opentelemetry/instrumentation' - '@opentelemetry/sdk-trace-base' - - '@opentelemetry/semantic-conventions' - encoding - react - supports-color - '@sentry/node@8.13.0': + '@sentry/node@8.20.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-connect': 0.37.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-express': 0.40.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-fastify': 0.37.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-graphql': 0.41.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-hapi': 0.39.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-connect': 0.38.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-express': 0.41.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fastify': 0.38.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-graphql': 0.42.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-hapi': 0.40.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-http': 0.52.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-ioredis': 0.41.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-koa': 0.41.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongodb': 0.45.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongoose': 0.39.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql': 0.39.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql2': 0.39.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-nestjs-core': 0.38.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-pg': 0.42.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-redis-4': 0.40.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-ioredis': 0.42.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-koa': 0.42.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongodb': 0.46.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongoose': 0.40.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql': 0.40.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.40.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-nestjs-core': 0.39.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pg': 0.43.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis-4': 0.41.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.25.1 - '@prisma/instrumentation': 5.16.0 - '@sentry/core': 8.13.0 - '@sentry/opentelemetry': 8.13.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1) - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@prisma/instrumentation': 5.17.0 + '@sentry/core': 8.20.0 + '@sentry/opentelemetry': 8.20.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1) + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 + import-in-the-middle: 1.10.0 optionalDependencies: - opentelemetry-instrumentation-fetch-node: 1.2.0 + opentelemetry-instrumentation-fetch-node: 1.2.3(@opentelemetry/api@1.9.0) transitivePeerDependencies: - supports-color - '@sentry/opentelemetry@8.13.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1)': + '@sentry/opentelemetry@8.20.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.25.1 - '@sentry/core': 8.13.0 - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@sentry/core': 8.20.0 + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 - '@sentry/react@8.13.0(react@18.3.1)': + '@sentry/react@8.20.0(react@18.3.1)': dependencies: - '@sentry/browser': 8.13.0 - '@sentry/core': 8.13.0 - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@sentry/browser': 8.20.0 + '@sentry/core': 8.20.0 + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 hoist-non-react-statics: 3.3.2 react: 18.3.1 '@sentry/types@6.19.7': {} - '@sentry/types@8.13.0': {} + '@sentry/types@8.20.0': {} '@sentry/utils@6.19.7': dependencies: '@sentry/types': 6.19.7 tslib: 1.14.1 - '@sentry/utils@8.13.0': + '@sentry/utils@8.20.0': dependencies: - '@sentry/types': 8.13.0 + '@sentry/types': 8.20.0 - '@sentry/vercel-edge@8.13.0': + '@sentry/vercel-edge@8.20.0': dependencies: - '@sentry/core': 8.13.0 - '@sentry/types': 8.13.0 - '@sentry/utils': 8.13.0 + '@sentry/core': 8.20.0 + '@sentry/types': 8.20.0 + '@sentry/utils': 8.20.0 '@sentry/webpack-plugin@2.20.1(webpack@5.92.1)': dependencies: @@ -9510,11 +9467,7 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tsconfig/recommended@1.0.6': {} - - '@types/accepts@1.3.7': - dependencies: - '@types/node': 20.14.11 + '@tsconfig/recommended@1.0.7': {} '@types/babel__core@7.20.5': dependencies: @@ -9537,27 +9490,11 @@ snapshots: dependencies: '@babel/types': 7.24.7 - '@types/body-parser@1.19.5': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 20.14.11 - '@types/connect@3.4.36': dependencies: '@types/node': 20.14.11 - '@types/connect@3.4.38': - dependencies: - '@types/node': 20.14.11 - - '@types/content-disposition@0.5.8': {} - - '@types/cookies@0.9.0': - dependencies: - '@types/connect': 3.4.38 - '@types/express': 4.17.21 - '@types/keygrip': 1.0.6 - '@types/node': 20.14.11 + '@types/crypto-js@4.2.2': {} '@types/eslint-scope@3.7.7': dependencies: @@ -9571,20 +9508,6 @@ snapshots: '@types/estree@1.0.5': {} - '@types/express-serve-static-core@4.19.5': - dependencies: - '@types/node': 20.14.11 - '@types/qs': 6.9.15 - '@types/range-parser': 1.2.7 - '@types/send': 0.17.4 - - '@types/express@4.17.21': - dependencies: - '@types/body-parser': 1.19.5 - '@types/express-serve-static-core': 4.19.5 - '@types/qs': 6.9.15 - '@types/serve-static': 1.15.7 - '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 @@ -9600,12 +9523,8 @@ snapshots: '@types/react': 18.3.3 hoist-non-react-statics: 3.3.2 - '@types/http-assert@1.5.5': {} - '@types/http-cache-semantics@4.0.4': {} - '@types/http-errors@2.0.4': {} - '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -9628,29 +9547,6 @@ snapshots: '@types/node': 20.14.11 optional: true - '@types/keygrip@1.0.6': {} - - '@types/koa-compose@3.2.8': - dependencies: - '@types/koa': 2.14.0 - - '@types/koa@2.14.0': - dependencies: - '@types/accepts': 1.3.7 - '@types/content-disposition': 0.5.8 - '@types/cookies': 0.9.0 - '@types/http-assert': 1.5.5 - '@types/http-errors': 2.0.4 - '@types/keygrip': 1.0.6 - '@types/koa-compose': 3.2.8 - '@types/node': 20.14.11 - - '@types/koa__router@12.0.3': - dependencies: - '@types/koa': 2.14.0 - - '@types/mime@1.3.5': {} - '@types/mysql@2.15.22': dependencies: '@types/node': 20.14.11 @@ -9673,6 +9569,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@22.4.0': + dependencies: + undici-types: 6.19.6 + '@types/parse-json@4.0.2': {} '@types/pg-pool@2.0.4': @@ -9687,10 +9587,6 @@ snapshots: '@types/prop-types@15.7.12': {} - '@types/qs@6.9.15': {} - - '@types/range-parser@1.2.7': {} - '@types/react-dom@18.3.0': dependencies: '@types/react': 18.3.3 @@ -9706,17 +9602,6 @@ snapshots: '@types/semver@7.5.8': {} - '@types/send@0.17.4': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 20.14.11 - - '@types/serve-static@1.15.7': - dependencies: - '@types/http-errors': 2.0.4 - '@types/node': 20.14.11 - '@types/send': 0.17.4 - '@types/shimmer@1.0.5': {} '@types/stack-utils@2.0.3': {} @@ -10052,13 +9937,13 @@ snapshots: stubborn-fs: 1.2.5 when-exit: 2.1.2 - autoprefixer@10.4.19(postcss@8.4.38): + autoprefixer@10.4.20(postcss@8.4.38): dependencies: - browserslist: 4.23.0 - caniuse-lite: 1.0.30001607 + browserslist: 4.23.3 + caniuse-lite: 1.0.30001647 fraction.js: 4.3.7 normalize-range: 0.1.2 - picocolors: 1.0.0 + picocolors: 1.0.1 postcss: 8.4.38 postcss-value-parser: 4.2.0 @@ -10227,19 +10112,12 @@ snapshots: brorand@1.1.0: {} - browserslist@4.23.0: + browserslist@4.23.3: dependencies: - caniuse-lite: 1.0.30001607 - electron-to-chromium: 1.4.729 - node-releases: 2.0.14 - update-browserslist-db: 1.0.13(browserslist@4.23.0) - - browserslist@4.23.1: - dependencies: - caniuse-lite: 1.0.30001633 - electron-to-chromium: 1.4.801 - node-releases: 2.0.14 - update-browserslist-db: 1.0.16(browserslist@4.23.1) + caniuse-lite: 1.0.30001647 + electron-to-chromium: 1.5.4 + node-releases: 2.0.18 + update-browserslist-db: 1.1.0(browserslist@4.23.3) bs-logger@0.2.6: dependencies: @@ -10309,10 +10187,10 @@ snapshots: camelize@1.0.1: {} - caniuse-lite@1.0.30001607: {} - caniuse-lite@1.0.30001633: {} + caniuse-lite@1.0.30001647: {} + chalk@2.3.0: dependencies: ansi-styles: 3.2.1 @@ -10548,7 +10426,7 @@ snapshots: core-js-compat@3.37.1: dependencies: - browserslist: 4.23.1 + browserslist: 4.23.3 core-js@3.37.1: {} @@ -10569,13 +10447,13 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 - create-jest@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)): + create-jest@29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + jest-config: 29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -10598,6 +10476,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + crypto-random-string@4.0.0: dependencies: type-fest: 1.4.0 @@ -10809,9 +10689,7 @@ snapshots: dependencies: jake: 10.8.7 - electron-to-chromium@1.4.729: {} - - electron-to-chromium@1.4.801: {} + electron-to-chromium@1.5.4: {} elliptic@6.5.4: dependencies: @@ -11038,7 +10916,7 @@ snapshots: - bufferutil - utf-8-validate - ethers@6.13.1: + ethers@6.13.2: dependencies: '@adraffy/ens-normalize': 1.10.1 '@noble/curves': 1.2.0 @@ -11571,20 +11449,20 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-in-the-middle@1.4.2: + import-in-the-middle@1.10.0: dependencies: acorn: 8.12.1 - acorn-import-assertions: 1.9.0(acorn@8.12.1) + acorn-import-attributes: 1.9.5(acorn@8.12.1) cjs-module-lexer: 1.2.3 module-details-from-path: 1.0.3 - optional: true - import-in-the-middle@1.8.1: + import-in-the-middle@1.7.1: dependencies: acorn: 8.12.1 - acorn-import-attributes: 1.9.5(acorn@8.12.1) + acorn-import-assertions: 1.9.0(acorn@8.12.1) cjs-module-lexer: 1.2.3 module-details-from-path: 1.0.3 + optional: true import-lazy@4.0.0: {} @@ -11845,16 +11723,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)): + jest-cli@29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + create-jest: 29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + jest-config: 29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -11864,7 +11742,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)): + jest-config@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)): dependencies: '@babel/core': 7.24.4 '@jest/test-sequencer': 29.7.0 @@ -11890,7 +11768,38 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.14.11 - ts-node: 10.9.2(@types/node@20.14.11)(typescript@5.5.3) + ts-node: 10.9.2(@types/node@22.4.0)(typescript@5.5.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)): + dependencies: + '@babel/core': 7.24.4 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.24.4) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0(babel-plugin-macros@3.1.0) + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.7 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.4.0 + ts-node: 10.9.2(@types/node@22.4.0)(typescript@5.5.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -12116,12 +12025,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)): + jest@29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)) '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + jest-cli: 29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -12712,7 +12621,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.14: {} + node-releases@2.0.18: {} node-stream-zip@1.15.0: {} @@ -12773,10 +12682,10 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - opentelemetry-instrumentation-fetch-node@1.2.0: + opentelemetry-instrumentation-fetch-node@1.2.3(@opentelemetry/api@1.9.0): dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.43.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.46.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.25.1 transitivePeerDependencies: - supports-color @@ -13831,11 +13740,11 @@ snapshots: dependencies: typescript: 5.5.3 - ts-jest@29.1.5(@babel/core@7.24.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.4))(jest@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)))(typescript@5.5.3): + ts-jest@29.1.5(@babel/core@7.24.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.4))(jest@29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)))(typescript@5.5.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + jest: 29.7.0(@types/node@22.4.0)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3)) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -13849,14 +13758,14 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.24.4) - ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3): + ts-node@10.9.2(@types/node@22.4.0)(typescript@5.5.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.14.11 + '@types/node': 22.4.0 acorn: 8.11.3 acorn-walk: 8.3.1 arg: 4.1.3 @@ -13948,6 +13857,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.19.6: {} + unicode-canonical-property-names-ecmascript@2.0.0: {} unicode-match-property-ecmascript@2.0.0: @@ -13978,15 +13889,9 @@ snapshots: webpack-sources: 3.2.3 webpack-virtual-modules: 0.5.0 - update-browserslist-db@1.0.13(browserslist@4.23.0): - dependencies: - browserslist: 4.23.0 - escalade: 3.1.2 - picocolors: 1.0.1 - - update-browserslist-db@1.0.16(browserslist@4.23.1): + update-browserslist-db@1.1.0(browserslist@4.23.3): dependencies: - browserslist: 4.23.1 + browserslist: 4.23.3 escalade: 3.1.2 picocolors: 1.0.1 @@ -14073,7 +13978,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.12.1 acorn: 8.12.1 acorn-import-attributes: 1.9.5(acorn@8.12.1) - browserslist: 4.23.1 + browserslist: 4.23.3 chrome-trace-event: 1.0.4 enhanced-resolve: 5.17.0 es-module-lexer: 1.5.4 @@ -14228,7 +14133,7 @@ snapshots: yocto-queue@0.1.0: {} - zx@8.1.2: + zx@8.1.4: optionalDependencies: '@types/fs-extra': 11.0.4 '@types/node': 20.14.11