From acd3a080d347a4039c2be3194601cf8f30f7431d Mon Sep 17 00:00:00 2001 From: Louis Aussedat Date: Mon, 2 Sep 2024 15:31:53 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(keyring-eth):=20Creat?= =?UTF-8?q?e=20args=20type=20for=20BuildTransactionContextTask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/BuildTransactionContextTask.test.ts | 62 ++++++------------- .../task/BuildTransactionContextTask.ts | 26 ++++---- 2 files changed, 34 insertions(+), 54 deletions(-) diff --git a/packages/signer/keyring-eth/src/internal/app-binder/task/BuildTransactionContextTask.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/task/BuildTransactionContextTask.test.ts index 060d584f8..3838d925e 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/task/BuildTransactionContextTask.test.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/task/BuildTransactionContextTask.test.ts @@ -8,7 +8,10 @@ import { Left, Right } from "purify-ts"; import { TransactionMapperResult } from "@internal/transaction/service/mapper/model/TransactionMapperResult"; import { TransactionMapperService } from "@internal/transaction/service/mapper/TransactionMapperService"; -import { BuildTransactionContextTask } from "./BuildTransactionContextTask"; +import { + BuildTransactionContextTask, + BuildTransactionContextTaskArgs, +} from "./BuildTransactionContextTask"; describe("BuildTransactionContextTask", () => { const contextModuleMock = { @@ -22,6 +25,7 @@ describe("BuildTransactionContextTask", () => { domain: "domain-name.eth", }; let defaultTransaction: Transaction; + let defaultArgs: BuildTransactionContextTaskArgs; beforeEach(() => { jest.clearAllMocks(); @@ -30,6 +34,14 @@ describe("BuildTransactionContextTask", () => { defaultTransaction.chainId = 1n; defaultTransaction.nonce = 0; defaultTransaction.data = "0x"; + + defaultArgs = { + contextModule: contextModuleMock, + mapper: mapperMock as unknown as TransactionMapperService, + transaction: defaultTransaction, + options: defaultOptions, + challenge: "challenge", + }; }); it("should build the transaction context without clear sign contexts", async () => { @@ -44,13 +56,7 @@ describe("BuildTransactionContextTask", () => { contextModuleMock.getContexts.mockResolvedValueOnce(clearSignContexts); // WHEN - const result = await new BuildTransactionContextTask( - contextModuleMock, - mapperMock as unknown as TransactionMapperService, - defaultTransaction, - defaultOptions, - "challenge", - ).run(); + const result = await new BuildTransactionContextTask(defaultArgs).run(); // THEN expect(result).toEqual({ @@ -80,13 +86,7 @@ describe("BuildTransactionContextTask", () => { contextModuleMock.getContexts.mockResolvedValueOnce(clearSignContexts); // WHEN - const result = await new BuildTransactionContextTask( - contextModuleMock, - mapperMock as unknown as TransactionMapperService, - defaultTransaction, - defaultOptions, - "challenge", - ).run(); + const result = await new BuildTransactionContextTask(defaultArgs).run(); // THEN expect(result).toEqual({ @@ -107,13 +107,7 @@ describe("BuildTransactionContextTask", () => { contextModuleMock.getContexts.mockResolvedValueOnce(clearSignContexts); // WHEN - await new BuildTransactionContextTask( - contextModuleMock, - mapperMock as unknown as TransactionMapperService, - defaultTransaction, - defaultOptions, - "challenge", - ).run(); + await new BuildTransactionContextTask(defaultArgs).run(); // THEN expect(mapperMock.mapTransactionToSubset).toHaveBeenCalledWith( @@ -133,13 +127,7 @@ describe("BuildTransactionContextTask", () => { contextModuleMock.getContexts.mockResolvedValueOnce(clearSignContexts); // WHEN - await new BuildTransactionContextTask( - contextModuleMock, - mapperMock as unknown as TransactionMapperService, - defaultTransaction, - defaultOptions, - "challenge", - ).run(); + await new BuildTransactionContextTask(defaultArgs).run(); // THEN expect(contextModuleMock.getContexts).toHaveBeenCalledWith({ @@ -155,13 +143,7 @@ describe("BuildTransactionContextTask", () => { mapperMock.mapTransactionToSubset.mockReturnValueOnce(Left(error)); // WHEN - const task = new BuildTransactionContextTask( - contextModuleMock, - mapperMock as unknown as TransactionMapperService, - defaultTransaction, - defaultOptions, - "challenge", - ); + const task = new BuildTransactionContextTask(defaultArgs); // THEN await expect(task.run()).rejects.toThrow(error); @@ -196,13 +178,7 @@ describe("BuildTransactionContextTask", () => { contextModuleMock.getContexts.mockResolvedValueOnce(clearSignContexts); // WHEN - const result = await new BuildTransactionContextTask( - contextModuleMock, - mapperMock as unknown as TransactionMapperService, - defaultTransaction, - defaultOptions, - "challenge", - ).run(); + const result = await new BuildTransactionContextTask(defaultArgs).run(); // THEN expect(result).toEqual({ diff --git a/packages/signer/keyring-eth/src/internal/app-binder/task/BuildTransactionContextTask.ts b/packages/signer/keyring-eth/src/internal/app-binder/task/BuildTransactionContextTask.ts index 798a2508a..3050151cd 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/task/BuildTransactionContextTask.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/task/BuildTransactionContextTask.ts @@ -12,25 +12,29 @@ export type BuildTransactionTaskResult = { readonly serializedTransaction: Uint8Array; }; +export type BuildTransactionContextTaskArgs = { + readonly contextModule: ContextModule; + readonly mapper: TransactionMapperService; + readonly transaction: Transaction; + readonly options: TransactionOptions; + readonly challenge: string; +}; + export class BuildTransactionContextTask { - constructor( - private contextModule: ContextModule, - private mapper: TransactionMapperService, - private transaction: Transaction, - private options: TransactionOptions, - private challenge: string, - ) {} + constructor(private readonly args: BuildTransactionContextTaskArgs) {} async run(): Promise { - const parsed = this.mapper.mapTransactionToSubset(this.transaction); + const { contextModule, mapper, transaction, options, challenge } = + this.args; + const parsed = mapper.mapTransactionToSubset(transaction); parsed.ifLeft((err) => { throw err; }); const { subset, serializedTransaction } = parsed.unsafeCoerce(); - const clearSignContexts = await this.contextModule.getContexts({ - challenge: this.challenge, - domain: this.options.domain, + const clearSignContexts = await contextModule.getContexts({ + challenge, + domain: options.domain, ...subset, }); From 5031f2727a0ea7bd44d946168a7dfd3477b91806 Mon Sep 17 00:00:00 2001 From: Louis Aussedat Date: Mon, 2 Sep 2024 17:01:21 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B=20(keyring-eth):=20Fix=20Sign?= =?UTF-8?q?=20commands=20when=20v=20is=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/internal/app-binder/command/SignEIP712Command.ts | 2 +- .../internal/app-binder/command/SignPersonalMessageCommand.ts | 2 +- .../src/internal/app-binder/command/SignTransactionCommand.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 de1bd1216..0e897ec11 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 @@ -79,7 +79,7 @@ export class SignEIP712Command } const v = parser.extract8BitUInt(); - if (!v) { + if (v === undefined) { return CommandResultFactory({ error: new InvalidStatusWordError("V is missing"), }); 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 5dbe4769c..12c46d9ea 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 @@ -69,7 +69,7 @@ export class SignPersonalMessageCommand // The data is returned only for the last chunk const v = parser.extract8BitUInt(); - if (!v) { + if (v === undefined) { return CommandResultFactory({ data: Nothing }); } 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 73722c4f0..4b531612d 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 @@ -71,7 +71,7 @@ export class SignTransactionCommand // The data is returned only for the last chunk const v = parser.extract8BitUInt(); - if (!v) { + if (v === undefined) { return CommandResultFactory({ data: Nothing }); } From a5d98b008457732d6c7bae07c1e76e470710e599 Mon Sep 17 00:00:00 2001 From: Louis Aussedat Date: Mon, 2 Sep 2024 11:19:37 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20(keyring-eth):=20Fix=20Provi?= =?UTF-8?q?deTokenInformationCommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/ProvideTokenInformationCommand.test.ts | 14 -------------- .../command/ProvideTokenInformationCommand.ts | 8 +------- 2 files changed, 1 insertion(+), 21 deletions(-) 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 index fe6ae8a9f..e43192e6f 100644 --- 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 @@ -83,20 +83,6 @@ describe("ProvideTokenInformationCommand", () => { ); }); - it("should return an error if the response is invalid", () => { - // GIVEN - const response = { - statusCode: Uint8Array.from([0x90, 0x00]), - data: new Uint8Array(), - }; - - // WHEN - const result = command.parseResponse(response); - - // THEN - expect(isSuccessCommandResult(result)).toBe(false); - }); - it("should return an error if the response is not successful", () => { // GIVEN const response = { 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 cbe77ef8b..3b79bb29b 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 @@ -10,7 +10,6 @@ import { CommandResultFactory, CommandUtils, GlobalCommandErrorHandler, - InvalidStatusWordError, } from "@ledgerhq/device-sdk-core"; export type ProvideTokenInformationCommandArgs = { @@ -52,12 +51,7 @@ export class ProvideTokenInformationCommand error: GlobalCommandErrorHandler.handle(response), }); } - const tokenIndex = parser.extract8BitUInt(); - if (tokenIndex === undefined) { - return CommandResultFactory({ - error: new InvalidStatusWordError("tokenIndex is missing"), - }); - } + const tokenIndex = parser.extract8BitUInt() ?? 0; return CommandResultFactory({ data: { tokenIndex } }); } } From a86b708aaac8af272a0b12e05862682c67b64eec Mon Sep 17 00:00:00 2001 From: Louis Aussedat Date: Mon, 2 Sep 2024 09:52:47 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20(keyring-eth):=20Re?= =?UTF-8?q?place=20ErrorCode=20with=20more=20explicit=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../task/ProvideTransactionContextTask.test.ts | 12 +++++++++--- .../task/ProvideTransactionContextTask.ts | 14 ++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/signer/keyring-eth/src/internal/app-binder/task/ProvideTransactionContextTask.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/task/ProvideTransactionContextTask.test.ts index 5a776d40b..647b09dba 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/task/ProvideTransactionContextTask.test.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/task/ProvideTransactionContextTask.test.ts @@ -12,17 +12,23 @@ import { SetPluginCommand } from "@internal/app-binder/command/SetPluginCommand" import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; import { - type ErrorCodes, ProvideTransactionContextTask, type ProvideTransactionContextTaskArgs, + type ProvideTransactionContextTaskErrorCodes, } from "./ProvideTransactionContextTask"; describe("ProvideTransactionContextTask", () => { const api = makeDeviceActionInternalApiMock(); - const successResult = CommandResultFactory({ + const successResult = CommandResultFactory< + void, + ProvideTransactionContextTaskErrorCodes + >({ data: undefined, }); - const errorResult = CommandResultFactory({ + const errorResult = CommandResultFactory< + void, + ProvideTransactionContextTaskErrorCodes + >({ data: undefined, error: {} as UnknownDeviceExchangeError, }); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/task/ProvideTransactionContextTask.ts b/packages/signer/keyring-eth/src/internal/app-binder/task/ProvideTransactionContextTask.ts index 313fa2c2c..9c8150c93 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/task/ProvideTransactionContextTask.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/task/ProvideTransactionContextTask.ts @@ -58,10 +58,7 @@ export class ProvideTransactionContextTaskError implements SdkError { } } -/** - * The exported type here is just for testing purposes, use the concret command error codes instead for the real implementation. - */ -export type ErrorCodes = +export type ProvideTransactionContextTaskErrorCodes = | void | SetExternalPluginCommandErrorCodes | SetPluginCommandErrorCodes @@ -84,7 +81,9 @@ export class ProvideTransactionContextTask { private args: ProvideTransactionContextTaskArgs, ) {} - async run(): Promise>> { + async run(): Promise< + Maybe> + > { for (const context of this.args.clearSignContexts) { const res = await this.provideContext(context); if (!isSuccessCommandResult(res)) { @@ -105,7 +104,10 @@ export class ProvideTransactionContextTask { type, payload, }: ClearSignContextSuccess): Promise< - CommandResult + CommandResult< + void | ProvideTokenInformationCommandResponse, + ProvideTransactionContextTaskErrorCodes + > > { switch (type) { case ClearSignContextType.PLUGIN: { From 9c00a48da29faa9b1c477ac570afece80199610d Mon Sep 17 00:00:00 2001 From: Louis Aussedat Date: Mon, 2 Sep 2024 16:47:03 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=A8=20(keyring-eth):=20Add=20SignTran?= =?UTF-8?q?sactionDeviceAction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SignTransactionDeviceActionTypes.ts | 61 ++ .../SignPersonalMessageDeviceAction.test.ts | 30 +- .../SignTransactionDeviceAction.test.ts | 720 ++++++++++++++++++ .../SignTransactionDeviceAction.ts | 381 +++++++++ .../SignTypedDataDeviceAction.test.ts | 31 +- .../__test-utils__/setupOpenAppDAMock.ts | 32 + .../__test-utils__/testDeviceActionStates.ts | 8 +- 7 files changed, 1201 insertions(+), 62 deletions(-) create mode 100644 packages/signer/keyring-eth/src/api/app-binder/SignTransactionDeviceActionTypes.ts create mode 100644 packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.test.ts create mode 100644 packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.ts create mode 100644 packages/signer/keyring-eth/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts diff --git a/packages/signer/keyring-eth/src/api/app-binder/SignTransactionDeviceActionTypes.ts b/packages/signer/keyring-eth/src/api/app-binder/SignTransactionDeviceActionTypes.ts new file mode 100644 index 000000000..0b7d1f185 --- /dev/null +++ b/packages/signer/keyring-eth/src/api/app-binder/SignTransactionDeviceActionTypes.ts @@ -0,0 +1,61 @@ +import { + ClearSignContextSuccess, + ContextModule, +} from "@ledgerhq/context-module"; +import { + CommandErrorResult, + DeviceActionState, + ExecuteDeviceActionReturnType, + OpenAppDAError, + OpenAppDARequiredInteraction, + UserInteractionRequired, +} from "@ledgerhq/device-sdk-core"; + +import { Signature } from "@api/model/Signature"; +import { Transaction } from "@api/model/Transaction"; +import { TransactionOptions } from "@api/model/TransactionOptions"; +import { ProvideTransactionContextTaskErrorCodes } from "@internal/app-binder/task/ProvideTransactionContextTask"; +import { TransactionMapperService } from "@internal/transaction/service/mapper/TransactionMapperService"; + +export type SignTransactionDAOutput = Signature; + +export type SignTransactionDAInput = { + readonly derivationPath: string; + readonly transaction: Transaction; + readonly mapper: TransactionMapperService; + readonly contextModule: ContextModule; + readonly options: TransactionOptions; +}; + +export type SignTransactionDAError = + | OpenAppDAError + | CommandErrorResult["error"] + | CommandErrorResult["error"]; + +type SignTransactionDARequiredInteraction = + | OpenAppDARequiredInteraction + | UserInteractionRequired.SignTransaction; + +export type SignTransactionDAIntermediateValue = { + requiredUserInteraction: SignTransactionDARequiredInteraction; +}; + +export type SignTransactionDAState = DeviceActionState< + SignTransactionDAOutput, + SignTransactionDAError, + SignTransactionDAIntermediateValue +>; + +export type SignTransactionDAInternalState = { + readonly error: SignTransactionDAError | null; + readonly challenge: string | null; + readonly clearSignContexts: ClearSignContextSuccess[] | null; + readonly serializedTransaction: Uint8Array | null; + readonly signature: Signature | null; +}; + +export type SignTransactionDAReturnType = ExecuteDeviceActionReturnType< + SignTransactionDAOutput, + SignTransactionDAError, + SignTransactionDAIntermediateValue +>; diff --git a/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignPersonalMessage/SignPersonalMessageDeviceAction.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignPersonalMessage/SignPersonalMessageDeviceAction.test.ts index 5982dda2a..8d94598ca 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignPersonalMessage/SignPersonalMessageDeviceAction.test.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignPersonalMessage/SignPersonalMessageDeviceAction.test.ts @@ -1,17 +1,15 @@ import { CommandResultFactory, DeviceActionStatus, - OpenAppDeviceAction, UnknownDeviceExchangeError, UserInteractionRequired, } from "@ledgerhq/device-sdk-core"; import { UnknownDAError } from "@ledgerhq/device-sdk-core"; import { InvalidStatusWordError } from "@ledgerhq/device-sdk-core"; -import { Left, Right } from "purify-ts"; -import { assign, createMachine } from "xstate"; import { SignPersonalMessageDAState } from "@api/index"; import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; +import { setupOpenAppDAMock } from "@internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock"; import { testDeviceActionStates } from "@internal/app-binder/device-action/__test-utils__/testDeviceActionStates"; import { SignPersonalMessageDeviceAction } from "./SignPersonalMessageDeviceAction"; @@ -27,32 +25,6 @@ jest.mock( }), ); -const setupOpenAppDAMock = (error?: unknown) => { - (OpenAppDeviceAction as jest.Mock).mockImplementation(() => ({ - makeStateMachine: jest.fn().mockImplementation(() => - createMachine({ - initial: "pending", - states: { - pending: { - entry: assign({ - intermediateValue: { - requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, - }, - }), - after: { - 0: "done", - }, - }, - done: { - type: "final", - }, - }, - output: () => (error ? Left(error) : Right(undefined)), - }), - ), - })); -}; - describe("SignPersonalMessageDeviceAction", () => { const signPersonalMessageMock = jest.fn(); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.test.ts new file mode 100644 index 000000000..4faee1eef --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.test.ts @@ -0,0 +1,720 @@ +import { ContextModule } from "@ledgerhq/context-module"; +import { + CommandResultFactory, + DeviceActionStatus, + UnknownDAError, + UserInteractionRequired, +} from "@ledgerhq/device-sdk-core"; +import { InvalidStatusWordError } from "@ledgerhq/device-sdk-core"; +import { Transaction } from "ethers-v6/transaction"; +import { Just, Nothing } from "purify-ts"; + +import { SignTransactionDAState } from "@api/app-binder/SignTransactionDeviceActionTypes"; +import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; +import { setupOpenAppDAMock } from "@internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock"; +import { testDeviceActionStates } from "@internal/app-binder/device-action/__test-utils__/testDeviceActionStates"; +import { TransactionMapperService } from "@internal/transaction/service/mapper/TransactionMapperService"; + +import { SignTransactionDeviceAction } from "./SignTransactionDeviceAction"; + +jest.mock( + "@ledgerhq/device-sdk-core", + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + () => ({ + ...jest.requireActual("@ledgerhq/device-sdk-core"), + OpenAppDeviceAction: jest.fn(() => ({ + makeStateMachine: jest.fn(), + })), + }), +); + +describe("SignTransactionDeviceAction", () => { + const contextModuleMock: ContextModule = { + getContexts: jest.fn(), + getTypedDataFilters: jest.fn(), + }; + const mapperMock: TransactionMapperService = { + mapTransactionToSubset: jest.fn(), + } as unknown as TransactionMapperService; + const getChallengeMock = jest.fn(); + const buildContextMock = jest.fn(); + const provideContextMock = jest.fn(); + const signTransactionMock = jest.fn(); + function extractDependenciesMock() { + return { + getChallenge: getChallengeMock, + buildContext: buildContextMock, + provideContext: provideContextMock, + signTransaction: signTransactionMock, + }; + } + const defaultOptions = { + domain: "domain-name.eth", + }; + let defaultTransaction: Transaction; + + beforeEach(() => { + jest.resetAllMocks(); + defaultTransaction = new Transaction(); + defaultTransaction.chainId = 1n; + defaultTransaction.nonce = 0; + defaultTransaction.data = "0x"; + }); + + describe("Happy path", () => { + it("should call external dependencies with the correct parameters", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + transaction: defaultTransaction, + options: defaultOptions, + contextModule: contextModuleMock, + mapper: mapperMock, + }, + }); + + // Mock the dependencies to return some sample data + getChallengeMock.mockResolvedValueOnce( + CommandResultFactory({ + data: { challenge: "challenge" }, + }), + ); + buildContextMock.mockResolvedValueOnce({ + clearSignContexts: [ + { + type: "token", + payload: "payload-1", + }, + ], + serializedTransaction: new Uint8Array([0x01, 0x02, 0x03]), + }); + provideContextMock.mockResolvedValueOnce(Nothing); + signTransactionMock.mockResolvedValueOnce( + CommandResultFactory({ + data: { + v: 0x1c, + r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788", + s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513", + }, + }), + ); + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + + // Expected intermediate values for the following state sequence: + // Initial -> OpenApp -> GetChallenge -> BuildContext -> ProvideContext -> SignTransaction + const expectedStates: Array = [ + // Initial state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // OpenApp interaction + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + // GetChallenge state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // BuildContext state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // ProvideContext state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // SignTransaction state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + status: DeviceActionStatus.Pending, + }, + // Final state + { + output: { + v: 0x1c, + r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788", + s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513", + }, + status: DeviceActionStatus.Completed, + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + + // Verify mocks calls parameters + observable.subscribe({ + complete: () => { + expect(getChallengeMock).toHaveBeenCalled(); + expect(buildContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + challenge: "challenge", + contextModule: contextModuleMock, + mapper: mapperMock, + options: defaultOptions, + transaction: defaultTransaction, + }, + }), + ); + expect(provideContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + clearSignContexts: [ + { + type: "token", + payload: "payload-1", + }, + ], + }, + }), + ); + expect(signTransactionMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + derivationPath: "44'/60'/0'/0/0", + serializedTransaction: new Uint8Array([0x01, 0x02, 0x03]), + }, + }), + ); + }, + }); + }); + }); + + describe("OpenApp errors", () => { + it("should fail if OpenApp throw an error", (done) => { + setupOpenAppDAMock(new UnknownDAError("OpenApp error")); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + transaction: defaultTransaction, + options: defaultOptions, + contextModule: contextModuleMock, + mapper: mapperMock, + }, + }); + + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + + const expectedStates: Array = [ + // Initial state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // OpenApp interaction + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + // OpenApp error + { + error: new UnknownDAError("OpenApp error"), + status: DeviceActionStatus.Error, + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); + + describe("GetChallenge errors", () => { + it("should fail if getChallenge returns an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + transaction: defaultTransaction, + options: defaultOptions, + contextModule: contextModuleMock, + mapper: mapperMock, + }, + }); + + getChallengeMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new InvalidStatusWordError("getChallenge error"), + }), + ); + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + + const expectedStates: Array = [ + // Initial state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // OpenApp interaction + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + // GetChallenge state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // GetChallenge error + { + error: new InvalidStatusWordError("getChallenge error"), + status: DeviceActionStatus.Error, + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should fail if getChallenge throws an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + transaction: defaultTransaction, + options: defaultOptions, + contextModule: contextModuleMock, + mapper: mapperMock, + }, + }); + + getChallengeMock.mockRejectedValueOnce( + new InvalidStatusWordError("getChallenge error"), + ); + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + + const expectedStates: Array = [ + // Initial state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // OpenApp interaction + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + // GetChallenge state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // GetChallenge error + { + error: new InvalidStatusWordError("getChallenge error"), + status: DeviceActionStatus.Error, + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); + + describe("BuildContext errors", () => { + it("should fail if buildContext throws an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + transaction: defaultTransaction, + options: defaultOptions, + contextModule: contextModuleMock, + mapper: mapperMock, + }, + }); + + getChallengeMock.mockResolvedValueOnce( + CommandResultFactory({ + data: { challenge: "challenge" }, + }), + ); + buildContextMock.mockRejectedValueOnce( + new InvalidStatusWordError("buildContext error"), + ); + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + + const expectedStates: Array = [ + // Initial state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // OpenApp interaction + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + // GetChallenge state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // BuildContext state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // BuildContext error + { + error: new InvalidStatusWordError("buildContext error"), + status: DeviceActionStatus.Error, + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); + + describe("ProvideContext errors", () => { + it("should fail if provideContext returns an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + transaction: defaultTransaction, + options: defaultOptions, + contextModule: contextModuleMock, + mapper: mapperMock, + }, + }); + + getChallengeMock.mockResolvedValueOnce( + CommandResultFactory({ + data: { challenge: "challenge" }, + }), + ); + buildContextMock.mockResolvedValueOnce({ + clearSignContexts: [ + { + type: "token", + payload: "payload-1", + }, + ], + serializedTransaction: new Uint8Array([0x01, 0x02, 0x03]), + }); + provideContextMock.mockResolvedValueOnce( + Just( + CommandResultFactory({ + error: new InvalidStatusWordError("provideContext error"), + }), + ), + ); + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + + const expectedStates: Array = [ + // Initial state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // OpenApp interaction + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + // GetChallenge state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // BuildContext state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // ProvideContext state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // ProvideContext error + { + error: new InvalidStatusWordError("provideContext error"), + status: DeviceActionStatus.Error, + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should fail if provideContext throws an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + transaction: defaultTransaction, + options: defaultOptions, + contextModule: contextModuleMock, + mapper: mapperMock, + }, + }); + + getChallengeMock.mockResolvedValueOnce( + CommandResultFactory({ + data: { challenge: "challenge" }, + }), + ); + buildContextMock.mockResolvedValueOnce({ + clearSignContexts: [ + { + type: "token", + payload: "payload-1", + }, + ], + serializedTransaction: new Uint8Array([0x01, 0x02, 0x03]), + }); + provideContextMock.mockRejectedValueOnce( + new InvalidStatusWordError("provideContext error"), + ); + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + + const expectedStates: Array = [ + // Initial state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // OpenApp interaction + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + // GetChallenge state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // BuildContext state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // ProvideContext state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // ProvideContext error + { + error: new InvalidStatusWordError("provideContext error"), + status: DeviceActionStatus.Error, + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); + + describe("SignTransaction errors", () => { + it("should fail if signTransaction returns an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + transaction: defaultTransaction, + options: defaultOptions, + contextModule: contextModuleMock, + mapper: mapperMock, + }, + }); + + getChallengeMock.mockResolvedValueOnce( + CommandResultFactory({ + data: { challenge: "challenge" }, + }), + ); + buildContextMock.mockResolvedValueOnce({ + clearSignContexts: [ + { + type: "token", + payload: "payload-1", + }, + ], + serializedTransaction: new Uint8Array([0x01, 0x02, 0x03]), + }); + provideContextMock.mockResolvedValueOnce(Nothing); + signTransactionMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new InvalidStatusWordError("signTransaction error"), + }), + ); + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + + const expectedStates: Array = [ + // Initial state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // OpenApp interaction + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + // GetChallenge state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // BuildContext state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // ProvideContext state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + // SignTransaction state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + status: DeviceActionStatus.Pending, + }, + // SignTransaction error + { + error: new InvalidStatusWordError("signTransaction error"), + status: DeviceActionStatus.Error, + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.ts b/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.ts new file mode 100644 index 000000000..1b37e3329 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.ts @@ -0,0 +1,381 @@ +import { + ClearSignContextSuccess, + ContextModule, +} from "@ledgerhq/context-module"; +import { + CommandErrorResult, + CommandResult, + InternalApi, + isSuccessCommandResult, + OpenAppDeviceAction, + StateMachineTypes, + UnknownDAError, + UserInteractionRequired, + XStateDeviceAction, +} from "@ledgerhq/device-sdk-core"; +import { Left, Maybe, Right } from "purify-ts"; +import { assign, fromPromise, setup } from "xstate"; + +import { + SignTransactionDAError, + SignTransactionDAInput, + SignTransactionDAIntermediateValue, + SignTransactionDAInternalState, + SignTransactionDAOutput, +} from "@api/app-binder/SignTransactionDeviceActionTypes"; +import { Signature } from "@api/model/Signature"; +import { Transaction } from "@api/model/Transaction"; +import { TransactionOptions } from "@api/model/TransactionOptions"; +import { + GetChallengeCommand, + GetChallengeCommandResponse, +} from "@internal/app-binder/command/GetChallengeCommand"; +import { + BuildTransactionContextTask, + BuildTransactionContextTaskArgs, + BuildTransactionTaskResult, +} from "@internal/app-binder/task/BuildTransactionContextTask"; +import { ProvideTransactionContextTask } from "@internal/app-binder/task/ProvideTransactionContextTask"; +import { ProvideTransactionContextTaskErrorCodes } from "@internal/app-binder/task/ProvideTransactionContextTask"; +import { SendSignTransactionTask } from "@internal/app-binder/task/SendSignTransactionTask"; +import { TransactionMapperService } from "@internal/transaction/service/mapper/TransactionMapperService"; + +export type MachineDependencies = { + readonly getChallenge: () => Promise< + CommandResult + >; + readonly buildContext: (arg0: { + input: { + contextModule: ContextModule; + mapper: TransactionMapperService; + transaction: Transaction; + options: TransactionOptions; + challenge: string; + }; + }) => Promise; + readonly provideContext: (arg0: { + input: { + clearSignContexts: ClearSignContextSuccess[]; + }; + }) => Promise< + Maybe> + >; + readonly signTransaction: (arg0: { + input: { + derivationPath: string; + serializedTransaction: Uint8Array; + }; + }) => Promise>; +}; + +export class SignTransactionDeviceAction extends XStateDeviceAction< + SignTransactionDAOutput, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAInternalState +> { + makeStateMachine(internalApi: InternalApi) { + type types = StateMachineTypes< + SignTransactionDAOutput, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAInternalState + >; + + const { getChallenge, buildContext, provideContext, signTransaction } = + this.extractDependencies(internalApi); + + return setup({ + types: { + input: {} as types["input"], + context: {} as types["context"], + output: {} as types["output"], + }, + actors: { + openAppStateMachine: new OpenAppDeviceAction({ + input: { appName: "Ethereum" }, + }).makeStateMachine(internalApi), + getChallenge: fromPromise(getChallenge), + buildContext: fromPromise(buildContext), + provideContext: fromPromise(provideContext), + signTransaction: fromPromise(signTransaction), + }, + guards: { + noInternalError: ({ context }) => context._internalState.error === null, + }, + actions: { + assignErrorFromEvent: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + error: _.event["error"], // NOTE: it should never happen, the error is not typed anymore here + }), + }), + }, + }).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QGUCWUB2AVATgQw1jwGMAXVAewwBEwA3VYsAQTMowDoB5ABzA2Y8etBk1bkqAYghUwHVBjoUA1nIp8BQ5KTykwAWRIALBWADaABgC6iUDwqxUEjLZAAPRABYAzAHYOAJwAHABMFgCMnhYWUSFBvgA0IACeiEEAbOkcAKxBQQGeASG+3tmeISEAvpVJaJi4BERsVCKMLM2cAMJGYMTKvPyCwvRt4uwASnAArgA2pJKWNkgg9o7Orh4IAQEWHN7RvgGH2dne4UHhSakI+Vnpvtkh4eme2Udh3tW16Nj4hCTOVpiDocbq9foaIZA9rOSawWbzMzhJZ2BxOdgbRDbXb7CyHY6nc6XFJpcIcc6+OLebzxCoBbLhL4gOq-RoA9jQsZUDgAcTApG6eBmM34MGksnkihUchgAqMQpFGBgi1cq3RVExCEi+Q4nnO6RC3ji232QSuiBKZPCEQyZwsmSCNKZLIa-w6nJBfLlCtFYEkYBwOAoOA4PBmugAZsGALYcWWC4W+lXLNXrZabbUBXX6w3GgKm803eLk7ZBTwZF6Uz41Zk-V1NQEjYHODgAISmqBmEE6VD0bnmMgwcgUSlUHAARh2uz2MH3SMnUWsMenEOFtv5HhYghEdt5PPvC7ks2v89EAvdwuFDc6638GxymzD2G2p93e2B+-7A8HQ+HSFGcFjSdOzfWcP3naxVTRNNQAzdccjCbc1wsPcDxJBBfFeDgQneDJHgZAib3qO92RaR8uU4AAFIMGAgMAZzncUh0lUc5B4GjUDohjwIXFZoOXWDEBCU4sl8DJwl8Xxogsc8YkLTwxI4TDlMwjJfHSCwqhrF0SPdciQWoihaPo99PwDIMQzDSMY1DDiuNMiCUT4pcNRXBBhO8UTxMk6TZM8QsNO8PYCkpHwghOIIiNZN1G1EJ9uUM4zuP7OEETBPoFkglN+NcwSEFxHEtziB49wCYlrj3XZhM8e5HQiCJCii+tSJofSW0SziTLAlLpjmdLlAWZEoJclw3IKvYiviU5CnKxBsi3DhHT8fd7hwmk9ya3TYtGEEdLZDomOHKUx0cYj9ucXjUwE9xEFKHDyRCfcHhCF411m9zIiU88jXyQ4-CvTbzofOKKI4PaYvYL8LN-azAI4U7ovvKhLpy0a8ru49HoUx5XrKwtqSzfdSgeeILjKmJAYhsiQd228gaoVK+p6DKUZGzVvHghrNKNEoHn89CT2zHYyjOalPIB7S6ap1qaZbcGkYwRm5XBQanKu3KbvyznIm54o-DKQsscW0I3nSZ4TkKalqhrDAKDo+BlnllqPRg5z1TRzWAFp0kLb2OGkgPA4OSmFZd58Bk0YZZeu9WPc2cpCzLEJdRkix5oI-cQ+dtrn36iOoRzhnetIYb3c1dIy11alHWyTIzfSbJEnQvIgmw14t1KwpMOrb4zulsPuS9BNFRgUvXYzc4goZTzgntE980T3wySNPEJPCfZyg5rO9Oj7l2xA5KS+ytm3OeI5FoqGrMiiEofELSlCaJMpsXCx7t+25tnw6+zuqPxcy7chUNOgRHovHwuUbGh5iwETeHEBkcR7Tv2BjtdqdkupziVv1Me11NjFCwshTSKEGTqX5tceauwojiwkluNcb9JZ91DoXTgTsOjYI1psM4+YEKXniK9N4704itx2CUUI4UGTBC0r3RG2dd7MKlgrTBzNlBsLjrdXmCFHT6kiNsV4htPK6keDVFeW4UKZ3odIneKDnzICmMQJgsAHb-3Hl4J4VUXpFGrjEUohY1xZHCLXE4-j8zkIUkg6mVjuQAFFvw4BUZqco1psLuMNDSLx2RDzAMblePU9I7QvWrNUIAA */ + id: "SignTransactionDeviceAction", + initial: "OpenAppDeviceAction", + context: ({ input }) => { + return { + input, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + _internalState: { + error: null, + clearSignContexts: null, + serializedTransaction: null, + challenge: null, + signature: null, + }, + }; + }, + states: { + OpenAppDeviceAction: { + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "openAppStateMachine", + input: { appName: "Ethereum" }, + src: "openAppStateMachine", + onSnapshot: { + actions: assign({ + intermediateValue: (_) => + _.event.snapshot.context.intermediateValue, + }), + }, + onDone: { + actions: assign({ + _internalState: (_) => { + return _.event.output.caseOf({ + Right: () => _.context._internalState, + Left: (error) => ({ + ..._.context._internalState, + error, + }), + }); + }, + }), + target: "CheckOpenAppDeviceActionResult", + }, + }, + }, + CheckOpenAppDeviceActionResult: { + always: [ + { + target: "GetChallenge", + guard: "noInternalError", + }, + "Error", + ], + }, + GetChallenge: { + invoke: { + id: "getChallenge", + src: "getChallenge", + onDone: { + target: "GetChallengeResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + challenge: event.output.data.challenge, + }; + } + return { + ...context._internalState, + error: event.output.error, + }; + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + GetChallengeResultCheck: { + always: [ + { + target: "BuildContext", + guard: "noInternalError", + }, + { + target: "Error", + }, + ], + }, + BuildContext: { + invoke: { + id: "buildContext", + src: "buildContext", + input: ({ context }) => ({ + contextModule: context.input.contextModule, + mapper: context.input.mapper, + transaction: context.input.transaction, + options: context.input.options, + challenge: context._internalState.challenge!, + }), + onDone: { + target: "ProvideContext", + actions: [ + assign({ + _internalState: ({ event, context }) => ({ + ...context._internalState, + clearSignContexts: event.output.clearSignContexts!, + serializedTransaction: event.output.serializedTransaction, + }), + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + ProvideContext: { + invoke: { + id: "provideContext", + src: "provideContext", + input: ({ context }) => ({ + clearSignContexts: context._internalState.clearSignContexts!, + }), + onDone: { + actions: assign({ + _internalState: ({ event, context }) => { + return event.output.caseOf({ + Just: (error) => ({ + ...context._internalState, + error: error.error, + }), + Nothing: () => context._internalState, + }); + }, + }), + target: "ProvideContextResultCheck", + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + ProvideContextResultCheck: { + always: [ + { + target: "SignTransaction", + guard: "noInternalError", + }, + { + target: "Error", + }, + ], + }, + SignTransaction: { + entry: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + }), + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "signTransaction", + src: "signTransaction", + input: ({ context }) => ({ + derivationPath: context.input.derivationPath, + serializedTransaction: + context._internalState.serializedTransaction!, + }), + onDone: { + target: "SignTransactionResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + signature: event.output.data, + }; + } + return { + ...context._internalState, + error: event.output.error, + }; + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + SignTransactionResultCheck: { + always: [ + { guard: "noInternalError", target: "Success" }, + { target: "Error" }, + ], + }, + Success: { + type: "final", + }, + Error: { + type: "final", + }, + }, + output: ({ context }) => + context._internalState.signature + ? Right(context._internalState.signature) + : Left( + context._internalState.error || + new UnknownDAError("No error in final state"), + ), + }); + } + + extractDependencies(internalApi: InternalApi): MachineDependencies { + const getChallenge = async () => + internalApi.sendCommand(new GetChallengeCommand()); + const buildContext = async (arg0: { + input: BuildTransactionContextTaskArgs; + }) => new BuildTransactionContextTask(arg0.input).run(); + + const provideContext = async (arg0: { + input: { + clearSignContexts: ClearSignContextSuccess[]; + }; + }) => + new ProvideTransactionContextTask(internalApi, { + clearSignContexts: arg0.input.clearSignContexts, + }).run(); + + const signTransaction = async (arg0: { + input: { + derivationPath: string; + serializedTransaction: Uint8Array; + }; + }) => new SendSignTransactionTask(internalApi, arg0.input).run(); + + return { + getChallenge, + buildContext, + provideContext, + signTransaction, + }; + } +} diff --git a/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTypedData/SignTypedDataDeviceAction.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTypedData/SignTypedDataDeviceAction.test.ts index 025004c50..509a5ad47 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTypedData/SignTypedDataDeviceAction.test.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/device-action/SignTypedData/SignTypedDataDeviceAction.test.ts @@ -2,16 +2,15 @@ import { type ContextModule } from "@ledgerhq/context-module"; import { CommandResultFactory, DeviceActionStatus, - OpenAppDeviceAction, UnknownDAError, UnknownDeviceExchangeError, UserInteractionRequired, } from "@ledgerhq/device-sdk-core"; -import { Just, Left, Nothing, Right } from "purify-ts"; -import { assign, createMachine } from "xstate"; +import { Just, Nothing } from "purify-ts"; import { SignTypedDataDAState } from "@api/app-binder/SignTypedDataDeviceActionTypes"; import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; +import { setupOpenAppDAMock } from "@internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock"; import { testDeviceActionStates } from "@internal/app-binder/device-action/__test-utils__/testDeviceActionStates"; import { type ProvideEIP712ContextTaskArgs } from "@internal/app-binder/task/ProvideEIP712ContextTask"; import { @@ -34,32 +33,6 @@ jest.mock( }), ); -const setupOpenAppDAMock = (error?: unknown) => { - (OpenAppDeviceAction as jest.Mock).mockImplementation(() => ({ - makeStateMachine: jest.fn().mockImplementation(() => - createMachine({ - initial: "pending", - states: { - pending: { - entry: assign({ - intermediateValue: { - requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, - }, - }), - after: { - 0: "done", - }, - }, - done: { - type: "final", - }, - }, - output: () => (error ? Left(error) : Right(undefined)), - }), - ), - })); -}; - describe("SignTypedDataDeviceAction", () => { const TEST_MESSAGE = { domain: {}, diff --git a/packages/signer/keyring-eth/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts b/packages/signer/keyring-eth/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts new file mode 100644 index 000000000..c62f6eb47 --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts @@ -0,0 +1,32 @@ +import { + OpenAppDeviceAction, + UserInteractionRequired, +} from "@ledgerhq/device-sdk-core"; +import { Left, Right } from "purify-ts"; +import { assign, createMachine } from "xstate"; + +export const setupOpenAppDAMock = (error?: unknown) => { + (OpenAppDeviceAction as jest.Mock).mockImplementation(() => ({ + makeStateMachine: jest.fn().mockImplementation(() => + createMachine({ + initial: "pending", + states: { + pending: { + entry: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }), + after: { + 0: "done", + }, + }, + done: { + type: "final", + }, + }, + output: () => (error ? Left(error) : Right(undefined)), + }), + ), + })); +}; diff --git a/packages/signer/keyring-eth/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts b/packages/signer/keyring-eth/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts index 9f46788e0..96170a28c 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts @@ -21,7 +21,7 @@ export function testDeviceActionStates< deviceAction: DeviceAction, expectedStates: Array>, internalApi: InternalApi, - done: jest.DoneCallback, + done?: jest.DoneCallback, ) { const observedStates: Array< DeviceActionState @@ -33,14 +33,14 @@ export function testDeviceActionStates< observedStates.push(state); }, error: (error) => { - done(error); + if (done) done(error); }, complete: () => { try { expect(observedStates).toEqual(expectedStates); - done(); + if (done) done(); } catch (e) { - done(e); + if (done) done(e); } }, }); From c14866288678a334cfc9fdeb271ef4e0b3c03061 Mon Sep 17 00:00:00 2001 From: Louis Aussedat Date: Mon, 2 Sep 2024 10:13:07 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=94=96=20(chore):=20Changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/serious-snails-deny.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/serious-snails-deny.md diff --git a/.changeset/serious-snails-deny.md b/.changeset/serious-snails-deny.md new file mode 100644 index 000000000..ebaf5b718 --- /dev/null +++ b/.changeset/serious-snails-deny.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": patch +--- + +Implement SignTransactionDeviceAction