From 925cb911297c85ac56d45cdbe0cd4f7e72c2234d Mon Sep 17 00:00:00 2001 From: fAnselmi-Ledger Date: Mon, 4 Nov 2024 09:36:00 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(signer-solana):=20Add=20solana=20S?= =?UTF-8?q?ignMessageUseCase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/tender-starfishes-cover.md | 5 + .../src/components/SignerSolanaView/index.tsx | 29 +- .../use-case/SignMessageUseCase.test.ts | 2 +- .../message/use-case/SignMessageUseCase.ts | 6 +- .../signer/signer-solana/src/api/index.ts | 5 + .../src/internal/DefaultSignerSolana.test.ts | 10 + .../src/internal/DefaultSignerSolana.ts | 9 +- .../app-binder/SolanaAppBinder.test.ts | 100 ++++++ .../internal/app-binder/SolanaAppBinder.ts | 13 +- .../SignOffChainMessageCommand.test.ts | 4 +- .../command/SignOffChainMessageCommand.ts | 15 +- .../command/SignTransactionCommand.test.ts | 1 - .../SignMessageDeviceAction.test.ts | 315 ++++++++++++++++++ .../SignMessage/SignMessageDeviceAction.ts | 260 +++++++++++++++ .../task/SendSignMessageTask.test.ts | 171 ++++++++++ .../app-binder/task/SendSignMessageTask.ts | 92 +++++ .../use-cases/di/useCasesModule.test.ts | 4 + .../internal/use-cases/di/useCasesModule.ts | 2 + .../internal/use-cases/di/useCasesTypes.ts | 1 + .../src/internal/use-cases/message/.gitkeep | 0 .../message/SignMessageUseCase.test.ts | 26 ++ .../use-cases/message/SignMessageUseCase.ts | 24 ++ 22 files changed, 1070 insertions(+), 24 deletions(-) create mode 100644 .changeset/tender-starfishes-cover.md create mode 100644 packages/signer/signer-solana/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts create mode 100644 packages/signer/signer-solana/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts create mode 100644 packages/signer/signer-solana/src/internal/app-binder/task/SendSignMessageTask.test.ts create mode 100644 packages/signer/signer-solana/src/internal/app-binder/task/SendSignMessageTask.ts delete mode 100644 packages/signer/signer-solana/src/internal/use-cases/message/.gitkeep create mode 100644 packages/signer/signer-solana/src/internal/use-cases/message/SignMessageUseCase.test.ts create mode 100644 packages/signer/signer-solana/src/internal/use-cases/message/SignMessageUseCase.ts diff --git a/.changeset/tender-starfishes-cover.md b/.changeset/tender-starfishes-cover.md new file mode 100644 index 000000000..bcb3f35ca --- /dev/null +++ b/.changeset/tender-starfishes-cover.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-solana": patch +--- + +Added Solana SignMessageUseCase diff --git a/apps/sample/src/components/SignerSolanaView/index.tsx b/apps/sample/src/components/SignerSolanaView/index.tsx index 29a78a41c..60d628fe3 100644 --- a/apps/sample/src/components/SignerSolanaView/index.tsx +++ b/apps/sample/src/components/SignerSolanaView/index.tsx @@ -11,6 +11,9 @@ import { type GetAppConfigurationDAIntermediateValue, type GetAppConfigurationDAOutput, SignerSolanaBuilder, + type SignMessageDAError, + type SignMessageDAIntermediateValue, + type SignMessageDAOutput, type SignTransactionDAError, type SignTransactionDAIntermediateValue, type SignTransactionDAOutput, @@ -20,7 +23,7 @@ import { DeviceActionsList } from "@/components/DeviceActionsView/DeviceActionsL import { type DeviceActionProps } from "@/components/DeviceActionsView/DeviceActionTester"; import { useDmk } from "@/providers/DeviceManagementKitProvider"; -const DEFAULT_DERIVATION_PATH = "44'/501'"; +const DEFAULT_DERIVATION_PATH = "44'/501'/0'/0'"; export const SignerSolanaView: React.FC<{ sessionId: string }> = ({ sessionId, @@ -87,6 +90,30 @@ export const SignerSolanaView: React.FC<{ sessionId: string }> = ({ SignTransactionDAError, SignTransactionDAIntermediateValue >, + { + title: "Sign off chain message", + description: + "Perform all the actions necessary to sign a solana off-chain message from the device", + executeDeviceAction: ({ derivationPath, message }) => { + if (!signer) { + throw new Error("Signer not initialized"); + } + return signer.signMessage(derivationPath, message); + }, + initialValues: { + derivationPath: DEFAULT_DERIVATION_PATH, + message: "Hello World", + }, + deviceModelId, + } satisfies DeviceActionProps< + SignMessageDAOutput, + { + derivationPath: string; + message: string; + }, + SignMessageDAError, + SignMessageDAIntermediateValue + >, { title: "Get app configuration", description: diff --git a/packages/signer/signer-eth/src/internal/message/use-case/SignMessageUseCase.test.ts b/packages/signer/signer-eth/src/internal/message/use-case/SignMessageUseCase.test.ts index a2a79d8b3..0c02dba22 100644 --- a/packages/signer/signer-eth/src/internal/message/use-case/SignMessageUseCase.test.ts +++ b/packages/signer/signer-eth/src/internal/message/use-case/SignMessageUseCase.test.ts @@ -5,7 +5,7 @@ import { SignMessageUseCase } from "./SignMessageUseCase"; describe("SignMessageUseCase", () => { it("should call signPersonalMessage on appBinder with the correct arguments", () => { // Given - const derivationPath = "44'/60'/0'/0/0"; + const derivationPath = "44'/501'/0'/0'"; const message = "Hello world"; const appBinder = { signPersonalMessage: jest.fn(), diff --git a/packages/signer/signer-eth/src/internal/message/use-case/SignMessageUseCase.ts b/packages/signer/signer-eth/src/internal/message/use-case/SignMessageUseCase.ts index f7acd775c..bea5fd10e 100644 --- a/packages/signer/signer-eth/src/internal/message/use-case/SignMessageUseCase.ts +++ b/packages/signer/signer-eth/src/internal/message/use-case/SignMessageUseCase.ts @@ -6,13 +6,13 @@ import { EthAppBinder } from "@internal/app-binder/EthAppBinder"; @injectable() export class SignMessageUseCase { - private _appBinding: EthAppBinder; + private _appBinder: EthAppBinder; constructor( @inject(appBinderTypes.AppBinding) appBinding: EthAppBinder, ) { - this._appBinding = appBinding; + this._appBinder = appBinding; } execute( @@ -20,7 +20,7 @@ export class SignMessageUseCase { message: string | Uint8Array, ): SignPersonalMessageDAReturnType { // 1- Sign the transaction using the app binding - return this._appBinding.signPersonalMessage({ + return this._appBinder.signPersonalMessage({ derivationPath, message, }); diff --git a/packages/signer/signer-solana/src/api/index.ts b/packages/signer/signer-solana/src/api/index.ts index 48e627bfa..0c4e2eb7c 100644 --- a/packages/signer/signer-solana/src/api/index.ts +++ b/packages/signer/signer-solana/src/api/index.ts @@ -9,6 +9,11 @@ export type { GetAppConfigurationDAIntermediateValue, GetAppConfigurationDAOutput, } from "@api/app-binder/GetAppConfigurationDeviceActionTypes"; +export type { + SignMessageDAError, + SignMessageDAIntermediateValue, + SignMessageDAOutput, +} from "@api/app-binder/SignMessageDeviceActionTypes"; export type { SignTransactionDAError, SignTransactionDAIntermediateValue, diff --git a/packages/signer/signer-solana/src/internal/DefaultSignerSolana.test.ts b/packages/signer/signer-solana/src/internal/DefaultSignerSolana.test.ts index 8d8010185..18e5d4745 100644 --- a/packages/signer/signer-solana/src/internal/DefaultSignerSolana.test.ts +++ b/packages/signer/signer-solana/src/internal/DefaultSignerSolana.test.ts @@ -34,6 +34,16 @@ describe("DefaultSignerSolana", () => { expect(dmk.executeDeviceAction).toHaveBeenCalled(); }); + it("should call signMessage", () => { + const dmk = { + executeDeviceAction: jest.fn(), + } as unknown as DeviceManagementKit; + const sessionId = {} as DeviceSessionId; + const signer = new DefaultSignerSolana({ dmk, sessionId }); + signer.signMessage("44'/501'/0'/0'", "Hello world"); + expect(dmk.executeDeviceAction).toHaveBeenCalled(); + }); + it("should call getAppConfiguration", () => { const dmk = { executeDeviceAction: jest.fn(), diff --git a/packages/signer/signer-solana/src/internal/DefaultSignerSolana.ts b/packages/signer/signer-solana/src/internal/DefaultSignerSolana.ts index e28e89c39..6329b3cce 100644 --- a/packages/signer/signer-solana/src/internal/DefaultSignerSolana.ts +++ b/packages/signer/signer-solana/src/internal/DefaultSignerSolana.ts @@ -16,6 +16,7 @@ import { type SignerSolana } from "@api/SignerSolana"; import { type GetAddressUseCase } from "./use-cases/address/GetAddressUseCase"; import { type GetAppConfigurationUseCase } from "./use-cases/app-configuration/GetAppConfigurationUseCase"; import { useCasesTypes } from "./use-cases/di/useCasesTypes"; +import { type SignMessageUseCase } from "./use-cases/message/SignMessageUseCase"; import { type SignTransactionUseCase } from "./use-cases/transaction/SignTransactionUseCase"; import { makeContainer } from "./di"; @@ -42,10 +43,12 @@ export class DefaultSignerSolana implements SignerSolana { } signMessage( - _derivationPath: string, - _message: string, + derivationPath: string, + message: string, ): SignMessageDAReturnType { - return {} as SignMessageDAReturnType; + return this._container + .get(useCasesTypes.SignMessageUseCase) + .execute(derivationPath, message); } getAddress( diff --git a/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.test.ts b/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.test.ts index 892925953..a742dd6b2 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.test.ts @@ -1,8 +1,10 @@ import { + type DeviceActionIntermediateValue, type DeviceActionState, DeviceActionStatus, type DeviceManagementKit, type DeviceSessionId, + type DmkError, SendCommandInAppDeviceAction, UserInteractionRequired, } from "@ledgerhq/device-management-kit"; @@ -22,7 +24,9 @@ import { type SignTransactionDAOutput, } from "@api/index"; +import { GetAppConfigurationCommand } from "./command/GetAppConfigurationCommand"; import { GetPubKeyCommand } from "./command/GetPubKeyCommand"; +import { SignMessageDeviceAction } from "./device-action/SignMessage/SignMessageDeviceAction"; import { SignTransactionDeviceAction } from "./device-action/SignTransactionDeviceAction"; import { SolanaAppBinder } from "./SolanaAppBinder"; @@ -235,6 +239,82 @@ describe("SolanaAppBinder", () => { }); }); + describe("signMessage", () => { + it("should return the signed message", (done) => { + // GIVEN + const signedMessage = new Uint8Array([0x1c, 0x8a, 0x54, 0x05, 0x10]); + const signMessageArgs = { + derivationPath: "44'/501'/0'/0'", + message: "Hello world", + }; + + jest.spyOn(mockedDmk, "executeDeviceAction").mockReturnValue({ + observable: from([ + { + status: DeviceActionStatus.Completed, + output: signedMessage, + } as DeviceActionState< + Uint8Array, + DmkError, + DeviceActionIntermediateValue + >, + ]), + cancel: jest.fn(), + }); + + // WHEN + const appBinder = new SolanaAppBinder(mockedDmk, "sessionId"); + const { observable } = appBinder.signMessage(signMessageArgs); + + // THEN + const states: DeviceActionState[] = []; + observable.subscribe({ + next: (state) => { + states.push(state); + }, + error: (err) => { + done(err); + }, + complete: () => { + try { + expect(states).toEqual([ + { + status: DeviceActionStatus.Completed, + output: signedMessage, + }, + ]); + done(); + } catch (err) { + done(err); + } + }, + }); + }); + + it("should call executeDeviceAction with correct parameters", () => { + // GIVEN + const signMessageArgs = { + derivationPath: "44'/501'/0'/0'", + message: "Hello world", + }; + + // WHEN + const appBinder = new SolanaAppBinder(mockedDmk, "sessionId"); + appBinder.signMessage(signMessageArgs); + + // THEN + expect(mockedDmk.executeDeviceAction).toHaveBeenCalledWith({ + sessionId: "sessionId", + deviceAction: new SignMessageDeviceAction({ + input: { + derivationPath: signMessageArgs.derivationPath, + message: signMessageArgs.message, + }, + }), + }); + }); + }); + describe("getAppConfiguration", () => { it("should return the app configuration", (done) => { // GIVEN @@ -290,5 +370,25 @@ describe("SolanaAppBinder", () => { }, }); }); + + it("should call executeDeviceAction with the correct params", () => { + // GIVEN + const appBinder = new SolanaAppBinder(mockedDmk, "sessionId"); + + // WHEN + appBinder.getAppConfiguration(); + + // THEN + expect(mockedDmk.executeDeviceAction).toHaveBeenCalledWith({ + sessionId: "sessionId", + deviceAction: new SendCommandInAppDeviceAction({ + input: { + command: new GetAppConfigurationCommand(), // Correct command + appName: "Solana", + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + }); + }); }); }); diff --git a/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.ts b/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.ts index 1fc29725a..f1ba0f5b0 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.ts @@ -16,6 +16,7 @@ import { externalTypes } from "@internal/externalTypes"; import { GetAppConfigurationCommand } from "./command/GetAppConfigurationCommand"; import { GetPubKeyCommand } from "./command/GetPubKeyCommand"; +import { SignMessageDeviceAction } from "./device-action/SignMessage/SignMessageDeviceAction"; import { SignTransactionDeviceAction } from "./device-action/SignTransactionDeviceAction"; @injectable() @@ -60,11 +61,19 @@ export class SolanaAppBinder { }); } - signMessage(_args: { + signMessage(args: { derivationPath: string; message: string; }): SignMessageDAReturnType { - return {} as SignMessageDAReturnType; + return this.dmk.executeDeviceAction({ + sessionId: this.sessionId, + deviceAction: new SignMessageDeviceAction({ + input: { + derivationPath: args.derivationPath, + message: args.message, + }, + }), + }); } getAppConfiguration(): GetAppConfigurationDAReturnType { diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/SignOffChainMessageCommand.test.ts b/packages/signer/signer-solana/src/internal/app-binder/command/SignOffChainMessageCommand.test.ts index a273082b8..0e438dd77 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/command/SignOffChainMessageCommand.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/command/SignOffChainMessageCommand.test.ts @@ -3,7 +3,6 @@ import { ApduResponse, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; -import { Just } from "purify-ts"; import { SignOffChainMessageCommand } from "./SignOffChainMessageCommand"; @@ -17,6 +16,7 @@ describe("SignOffChainMessageCommand", () => { beforeEach(() => { command = new SignOffChainMessageCommand({ message: MESSAGE, + derivationPath: "44'/501'/0'/0'", }); jest.clearAllMocks(); jest.requireActual("@ledgerhq/device-management-kit"); @@ -51,7 +51,7 @@ describe("SignOffChainMessageCommand", () => { expect(isSuccessCommandResult(parsed)).toBe(true); if (isSuccessCommandResult(parsed)) { - expect(parsed.data).toEqual(Just(signature)); + expect(parsed.data).toEqual(signature); } else { fail("Expected success result"); } diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/SignOffChainMessageCommand.ts b/packages/signer/signer-solana/src/internal/app-binder/command/SignOffChainMessageCommand.ts index db01df242..cd3457519 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/command/SignOffChainMessageCommand.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/command/SignOffChainMessageCommand.ts @@ -10,15 +10,15 @@ import { GlobalCommandErrorHandler, InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; -import { Just, type Maybe, Nothing } from "purify-ts"; import { type Signature } from "@api/model/Signature"; const SIGNATURE_LENGTH = 64; -export type SignOffChainMessageCommandResponse = Maybe; +export type SignOffChainMessageCommandResponse = Signature; export type SignOffChainMessageCommandArgs = { readonly message: Uint8Array; + readonly derivationPath: string; }; export class SignOffChainMessageCommand @@ -59,21 +59,14 @@ export class SignOffChainMessageCommand }); } - if (parser.getUnparsedRemainingLength() === 0) { - return CommandResultFactory({ - data: Nothing, - }); - } - const signature = parser.extractFieldByLength(SIGNATURE_LENGTH); if (!signature) { return CommandResultFactory({ - error: new InvalidStatusWordError("Unable to extract signature"), + error: new InvalidStatusWordError("Signature extraction failed"), }); } - return CommandResultFactory({ - data: Just(signature), + data: signature, }); } } diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/SignTransactionCommand.test.ts b/packages/signer/signer-solana/src/internal/app-binder/command/SignTransactionCommand.test.ts index e5f09abc9..268874a1a 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/command/SignTransactionCommand.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/command/SignTransactionCommand.test.ts @@ -132,7 +132,6 @@ describe("SignTransactionCommand", () => { it("should return the signature when the response is not empty", () => { // GIVEN const command = new SignTransactionCommand(defaultArgs); - // Uint8Array of 64 bytes const data = new Uint8Array(Array.from({ length: 64 }, (_, i) => i + 1)); // WHEN diff --git a/packages/signer/signer-solana/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts new file mode 100644 index 000000000..99c85a5bc --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts @@ -0,0 +1,315 @@ +import { + CommandResultFactory, + DeviceActionStatus, + UnknownDeviceExchangeError, + UserInteractionRequired, +} from "@ledgerhq/device-management-kit"; +import { UnknownDAError } from "@ledgerhq/device-management-kit"; + +import { type SignMessageDAState } from "@api/app-binder/SignMessageDeviceActionTypes"; +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 { SignMessageDeviceAction } from "@internal/app-binder/device-action/SignMessage/SignMessageDeviceAction"; + +jest.mock( + "@ledgerhq/device-management-kit", + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + () => ({ + ...jest.requireActual("@ledgerhq/device-management-kit"), + OpenAppDeviceAction: jest.fn(() => ({ + makeStateMachine: jest.fn(), + })), + }), +); + +describe("SignMessageDeviceAction", () => { + const signMessageMock = jest.fn(); + + function extractDependenciesMock() { + return { + signMessage: signMessageMock, + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("Success case", () => { + it("should call external dependencies with the correct parameters", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/501'/0'/0'", + message: "Hello world", + }, + }); + + jest + .spyOn(deviceAction, "extractDependencies") + .mockImplementation(() => extractDependenciesMock()); + + const signatureData = new Uint8Array([ + // signature data... + ]); + + signMessageMock.mockResolvedValueOnce( + CommandResultFactory({ + data: signatureData, + }), + ); + + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + status: DeviceActionStatus.Pending, + }, + { + output: signatureData, + status: DeviceActionStatus.Completed, + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + ); + + observable.subscribe({ + complete: () => { + try { + expect(signMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + derivationPath: "44'/501'/0'/0'", + sendingData: new TextEncoder().encode("Hello world"), + }), + }), + ); + done(); + } catch (error) { + done(error); + } + }, + error: (err) => { + done(err); + }, + }); + }); + }); + + describe("error cases", () => { + it("Error if the open app fails", (done) => { + setupOpenAppDAMock(new UnknownDeviceExchangeError("Mocked error")); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }); + + jest + .spyOn(deviceAction, "extractDependencies") + .mockImplementation(() => extractDependenciesMock()); + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + ); + + observable.subscribe({ + complete: () => { + try { + expect(signMessageMock).not.toHaveBeenCalled(); + done(); + } catch (error) { + done(error); + } + }, + error: (err) => { + done(err); + }, + }); + }); + + it("Error if the signMessage fails", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }); + + jest + .spyOn(deviceAction, "extractDependencies") + .mockImplementation(() => extractDependenciesMock()); + + signMessageMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Mocked error"), + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + ); + + observable.subscribe({ + complete: () => { + try { + expect(signMessageMock).toHaveBeenCalled(); + done(); + } catch (error) { + done(error); + } + }, + error: (err) => { + done(err); + }, + }); + }); + + it("Return a Left if the final state has no signature", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignMessageDeviceAction({ + input: { + derivationPath: "44'/60'/0'/0/0", + message: "Hello world", + }, + }); + + jest + .spyOn(deviceAction, "extractDependencies") + .mockImplementation(() => extractDependenciesMock()); + + signMessageMock.mockResolvedValueOnce( + CommandResultFactory({ + data: undefined, + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDAError("No error in final state"), + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + ); + + observable.subscribe({ + complete: () => { + try { + expect(signMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + derivationPath: "44'/60'/0'/0/0", + sendingData: new TextEncoder().encode("Hello world"), + }), + }), + ); + done(); + } catch (error) { + done(error); + } + }, + error: (err) => { + done(err); + }, + }); + }); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts new file mode 100644 index 000000000..1e9965967 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts @@ -0,0 +1,260 @@ +import { + type CommandResult, + //CommandResultStatus, + type DeviceActionStateMachine, + type InternalApi, + isSuccessCommandResult, + OpenAppDeviceAction, + type StateMachineTypes, + UnknownDAError, + UserInteractionRequired, + XStateDeviceAction, +} from "@ledgerhq/device-management-kit"; +import { Left, Right } from "purify-ts"; +import { assign, fromPromise, setup } from "xstate"; + +import { + type SignMessageDAError, + type SignMessageDAInput, + type SignMessageDAIntermediateValue, + type SignMessageDAInternalState, + type SignMessageDAOutput, +} from "@api/app-binder/SignMessageDeviceActionTypes"; +import { type Signature } from "@api/model/Signature"; +import { + SendSignMessageTask, + type SendSignMessageTaskArgs, +} from "@internal/app-binder/task/SendSignMessageTask"; + +export type MachineDependencies = { + readonly signMessage: (arg0: { + input: SendSignMessageTaskArgs; + }) => Promise>; +}; + +export type ExtractMachineDependencies = ( + internalApi: InternalApi, +) => MachineDependencies; + +export class SignMessageDeviceAction extends XStateDeviceAction< + SignMessageDAOutput, + SignMessageDAInput, + SignMessageDAError, + SignMessageDAIntermediateValue, + SignMessageDAInternalState +> { + makeStateMachine( + internalApi: InternalApi, + ): DeviceActionStateMachine< + SignMessageDAOutput, + SignMessageDAInput, + SignMessageDAError, + SignMessageDAIntermediateValue, + SignMessageDAInternalState + > { + type types = StateMachineTypes< + SignMessageDAOutput, + SignMessageDAInput, + SignMessageDAError, + SignMessageDAIntermediateValue, + SignMessageDAInternalState + >; + + const { signMessage } = this.extractDependencies(internalApi); + + return setup({ + types: { + input: {} as types["input"], + context: {} as types["context"], + output: {} as types["output"], + }, + actors: { + openAppStateMachine: new OpenAppDeviceAction({ + input: { appName: "Solana" }, + }).makeStateMachine(internalApi), + signMessage: fromPromise(signMessage), + }, + guards: { + noInternalError: ({ context }) => context._internalState.error === null, + messageSizeWithinLimit: ({ context }) => { + const messageSize = new TextEncoder().encode( + context.input.message, + ).length; + const apduHeaderSize = 5; + + return apduHeaderSize + messageSize <= 255; + }, + }, + actions: { + assignErrorFromEvent: assign({ + _internalState: ({ context, event }) => ({ + ...context._internalState, + error: event["data"] as SignMessageDAError, + }), + }), + assignErrorMessageTooBig: assign({ + _internalState: ({ context }) => ({ + ...context._internalState, + error: { + _tag: "InvalidMessageSizeError", + errorCode: "MessageTooLarge", + message: + "The APDU command exceeds the maximum allowable size (255 bytes)", + } as SignMessageDAError, + }), + }), + }, + }).createMachine({ + id: "SignMessageDeviceAction", + initial: "OpenAppDeviceAction", + context: ({ input }) => { + return { + input, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + _internalState: { + error: null, + signature: null, + }, + }; + }, + states: { + OpenAppDeviceAction: { + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "openAppStateMachine", + input: { appName: "Solana" }, + src: "openAppStateMachine", + onSnapshot: { + actions: assign({ + intermediateValue: ({ event }) => + event.snapshot.context.intermediateValue, + }), + }, + onDone: { + actions: assign({ + _internalState: ({ event, context }) => { + return event.output.caseOf({ + Right: () => context._internalState, + Left: (error) => ({ + ...context._internalState, + error, + }), + }); + }, + }), + target: "CheckOpenAppDeviceActionResult", + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + CheckOpenAppDeviceActionResult: { + always: [ + { + target: "CheckMessageSize", + guard: "noInternalError", + }, + "Error", + ], + }, + CheckMessageSize: { + always: [ + { + target: "SignMessage", + guard: "messageSizeWithinLimit", + }, + { + target: "Error", + actions: "assignErrorMessageTooBig", + }, + ], + }, + SignMessage: { + entry: assign({ + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignPersonalMessage, + }, + }), + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "signMessage", + src: "signMessage", + input: ({ context }) => ({ + derivationPath: context.input.derivationPath, + sendingData: new TextEncoder().encode(context.input.message), + }), + onDone: { + target: "SignMessageResultCheck", + 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", + }, + }, + }, + SignMessageResultCheck: { + 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 signMessage = async (arg0: { input: SendSignMessageTaskArgs }) => { + const result = await new SendSignMessageTask( + internalApi, + arg0.input, + ).run(); + return result; + }; + + return { + signMessage, + }; + } +} diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/SendSignMessageTask.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignMessageTask.test.ts new file mode 100644 index 000000000..89482866c --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignMessageTask.test.ts @@ -0,0 +1,171 @@ +import { + CommandResultFactory, + InvalidStatusWordError, +} from "@ledgerhq/device-management-kit"; + +import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; +import { SendSignMessageTask } from "@internal/app-binder/task/SendSignMessageTask"; + +const DERIVATION_PATH = "44'/501'/0'/0'"; + +describe("SendSignMessageTask", () => { + const apiMock = makeDeviceActionInternalApiMock(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const SIMPLE_MESSAGE = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + + describe("run with SignOffChainMessageCommand", () => { + it("should return an error if the command fails", async () => { + // GIVEN------------------------------- + //------------------------------------- + const args = { + derivationPath: DERIVATION_PATH, + sendingData: SIMPLE_MESSAGE, + }; + apiMock.sendCommand.mockResolvedValueOnce( + CommandResultFactory({ + error: new InvalidStatusWordError("no signature returned"), + }), + ); + + // WHEN-------------------------------- + //------------------------------------- + const result = await new SendSignMessageTask(apiMock, args).run(); + + // THEN-------------------------------- + //------------------------------------- + expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + error: new InvalidStatusWordError("no signature returned"), + }); + }); + + it("should return success when the command executes successfully", async () => { + // GIVEN------------------------------- + //------------------------------------- + const args = { + derivationPath: DERIVATION_PATH, + sendingData: SIMPLE_MESSAGE, + }; + const expectedSignature = new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]); + apiMock.sendCommand.mockResolvedValueOnce( + CommandResultFactory({ + data: expectedSignature, + }), + ); + + // WHEN-------------------------------- + //------------------------------------- + const result = await new SendSignMessageTask(apiMock, args).run(); + + // THEN-------------------------------- + //------------------------------------- + expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + data: expectedSignature, + }); + }); + + it("should handle invalid derivation paths", async () => { + // GIVEN------------------------------- + //------------------------------------- + const invalidDerivationPath = "invalid/path"; + const args = { + derivationPath: invalidDerivationPath, + sendingData: SIMPLE_MESSAGE, + }; + + // WHEN-------------------------------- + //------------------------------------- + const task = new SendSignMessageTask(apiMock, args); + + // THEN-------------------------------- + //------------------------------------- + await expect(task.run()).rejects.toThrowError(); + }); + + it("should handle empty message data", async () => { + // GIVEN------------------------------- + //------------------------------------- + const emptyMessage = new Uint8Array([]); + const args = { + derivationPath: DERIVATION_PATH, + sendingData: emptyMessage, + }; + apiMock.sendCommand.mockResolvedValueOnce( + CommandResultFactory({ + data: new Uint8Array([]), + }), + ); + + // WHEN-------------------------------- + //------------------------------------- + const result = await new SendSignMessageTask(apiMock, args).run(); + + // THEN-------------------------------- + //------------------------------------- + expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + if ("data" in result) { + expect(result.data).toEqual(new Uint8Array([])); + } else { + throw new Error("Expected result to have data property"); + } + }); + + it("should correctly build the APDU command", async () => { + // GIVEN------------------------------- + //------------------------------------- + const args = { + derivationPath: DERIVATION_PATH, + sendingData: SIMPLE_MESSAGE, + }; + const task = new SendSignMessageTask(apiMock, args); + const fullMessage = task["_buildFullMessage"](SIMPLE_MESSAGE); + const paths = [44 | 0x80000000, 501 | 0x80000000, 0 | 0x80000000, 0]; + const commandBuffer = task["_buildApduCommand"](fullMessage, paths); + + // WHEN-------------------------------- + //------------------------------------- + const expectedCommandLength = + 1 + // numberOfSigners + 1 + // numberOfDerivations + paths.length * 4 + // paths + fullMessage.length; // message + + // THEN-------------------------------- + //------------------------------------- + expect(commandBuffer.length).toEqual(expectedCommandLength); + }); + + it("should handle messages with maximum allowed length", async () => { + // GIVEN------------------------------- + //------------------------------------- + const maxLengthMessage = new Uint8Array(255).fill(0x01); + const args = { + derivationPath: DERIVATION_PATH, + sendingData: maxLengthMessage, + }; + apiMock.sendCommand.mockResolvedValueOnce( + CommandResultFactory({ + data: new Uint8Array([0x99, 0x88, 0x77]), + }), + ); + + // WHEN-------------------------------- + //------------------------------------- + const result = await new SendSignMessageTask(apiMock, args).run(); + + // THEN-------------------------------- + //------------------------------------- + expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + if ("data" in result) { + expect(result.data).toEqual(new Uint8Array([0x99, 0x88, 0x77])); + } else { + throw new Error("Expected result to have data property"); + } + }); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/SendSignMessageTask.ts b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignMessageTask.ts new file mode 100644 index 000000000..6613f7671 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignMessageTask.ts @@ -0,0 +1,92 @@ +import { + ByteArrayBuilder, + type CommandResult, + type InternalApi, +} from "@ledgerhq/device-management-kit"; +import { DerivationPathUtils } from "@ledgerhq/signer-utils"; + +import { SignOffChainMessageCommand } from "@internal/app-binder/command/SignOffChainMessageCommand"; + +export type SendSignMessageTaskArgs = { + sendingData: Uint8Array; + derivationPath: string; +}; + +export type SendSignMessageTaskRunFunctionReturn = Promise< + CommandResult +>; + +export class SendSignMessageTask { + constructor( + private api: InternalApi, + private args: SendSignMessageTaskArgs, + ) {} + + async run(): SendSignMessageTaskRunFunctionReturn { + const { sendingData, derivationPath } = this.args; + + const commandBuffer = this._buildApduCommand( + this._buildFullMessage(sendingData), + DerivationPathUtils.splitPath(derivationPath), + ); + + return await this.api.sendCommand( + new SignOffChainMessageCommand({ + message: commandBuffer, + derivationPath, + }), + ); + } + + private _buildFullMessage(sendingData: Uint8Array): Uint8Array { + const prefix = new TextEncoder().encode("solana offchain"); + const msgLengthFieldSize = 4; + + const lengthBytes = new Uint8Array(msgLengthFieldSize); + lengthBytes[2] = sendingData.length; + + const fullMessage = new Uint8Array( + 1 + prefix.length + lengthBytes.length + sendingData.length, + ); + + let offset = 0; + fullMessage[offset++] = 0xff; + fullMessage.set(prefix, offset); + offset += prefix.length; + fullMessage.set(lengthBytes, offset); + offset += lengthBytes.length; + fullMessage.set(sendingData, offset); + + return fullMessage; + } + + private _buildApduCommand( + fullMessage: Uint8Array, + paths: number[], + ): Uint8Array { + const numberOfSigners = 1; + const pathSize = 4; + const signersCountSize = 1; + const derivationsCountSize = 1; + const numberOfDerivations = paths.length; + const builder = new ByteArrayBuilder( + fullMessage.length + + signersCountSize + + derivationsCountSize + + numberOfDerivations * pathSize, + ); + + builder.add8BitUIntToData(numberOfSigners); + builder.add8BitUIntToData(numberOfDerivations); + + paths.forEach((path) => { + const buffer = new Uint8Array(4); + const view = new DataView(buffer.buffer); + view.setUint32(0, path, false); + builder.addBufferToData(buffer); + }); + + builder.addBufferToData(fullMessage); + return builder.build(); + } +} diff --git a/packages/signer/signer-solana/src/internal/use-cases/di/useCasesModule.test.ts b/packages/signer/signer-solana/src/internal/use-cases/di/useCasesModule.test.ts index 06becdafd..fbfb92305 100644 --- a/packages/signer/signer-solana/src/internal/use-cases/di/useCasesModule.test.ts +++ b/packages/signer/signer-solana/src/internal/use-cases/di/useCasesModule.test.ts @@ -32,5 +32,9 @@ describe("useCasesModuleFactory", () => { container.isBound(useCasesTypes.SignTransactionUseCase), ).toBeTruthy(); }); + + it("should bind SignMessageUseCase", () => { + expect(container.isBound(useCasesTypes.SignMessageUseCase)).toBeTruthy(); + }); }); }); diff --git a/packages/signer/signer-solana/src/internal/use-cases/di/useCasesModule.ts b/packages/signer/signer-solana/src/internal/use-cases/di/useCasesModule.ts index 9374fbc75..015b9a03f 100644 --- a/packages/signer/signer-solana/src/internal/use-cases/di/useCasesModule.ts +++ b/packages/signer/signer-solana/src/internal/use-cases/di/useCasesModule.ts @@ -3,6 +3,7 @@ import { ContainerModule } from "inversify"; import { GetAddressUseCase } from "@internal/use-cases/address/GetAddressUseCase"; import { GetAppConfigurationUseCase } from "@internal/use-cases/app-configuration/GetAppConfigurationUseCase"; import { useCasesTypes } from "@internal/use-cases/di/useCasesTypes"; +import { SignMessageUseCase } from "@internal/use-cases/message/SignMessageUseCase"; import { SignTransactionUseCase } from "@internal/use-cases/transaction/SignTransactionUseCase"; export const useCasesModuleFactory = () => @@ -21,5 +22,6 @@ export const useCasesModuleFactory = () => GetAppConfigurationUseCase, ); bind(useCasesTypes.SignTransactionUseCase).to(SignTransactionUseCase); + bind(useCasesTypes.SignMessageUseCase).to(SignMessageUseCase); }, ); diff --git a/packages/signer/signer-solana/src/internal/use-cases/di/useCasesTypes.ts b/packages/signer/signer-solana/src/internal/use-cases/di/useCasesTypes.ts index 64e4a432a..24e8a1836 100644 --- a/packages/signer/signer-solana/src/internal/use-cases/di/useCasesTypes.ts +++ b/packages/signer/signer-solana/src/internal/use-cases/di/useCasesTypes.ts @@ -2,4 +2,5 @@ export const useCasesTypes = { GetAddressUseCase: Symbol.for("GetAddressUseCase"), GetAppConfigurationUseCase: Symbol.for("GetAppConfigurationUseCase"), SignTransactionUseCase: Symbol.for("SignTransactionUseCase"), + SignMessageUseCase: Symbol.for("SignMessageUseCase"), }; diff --git a/packages/signer/signer-solana/src/internal/use-cases/message/.gitkeep b/packages/signer/signer-solana/src/internal/use-cases/message/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/signer/signer-solana/src/internal/use-cases/message/SignMessageUseCase.test.ts b/packages/signer/signer-solana/src/internal/use-cases/message/SignMessageUseCase.test.ts new file mode 100644 index 000000000..a9ef8258b --- /dev/null +++ b/packages/signer/signer-solana/src/internal/use-cases/message/SignMessageUseCase.test.ts @@ -0,0 +1,26 @@ +import { type SolanaAppBinder } from "@internal/app-binder/SolanaAppBinder"; + +import { SignMessageUseCase } from "./SignMessageUseCase"; + +describe("SignMessageUseCase", () => { + it("should call signMessage on appBinder with the correct arguments", () => { + // Given + const derivationPath = "44'/501'/0'/0'"; + const message = "Hello world"; + const appBinder = { + signMessage: jest.fn(), + }; + const signMessageUseCase = new SignMessageUseCase( + appBinder as unknown as SolanaAppBinder, + ); + + // When + signMessageUseCase.execute(derivationPath, message); + + // Then + expect(appBinder.signMessage).toHaveBeenCalledWith({ + derivationPath, + message, + }); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/use-cases/message/SignMessageUseCase.ts b/packages/signer/signer-solana/src/internal/use-cases/message/SignMessageUseCase.ts new file mode 100644 index 000000000..24400cfe2 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/use-cases/message/SignMessageUseCase.ts @@ -0,0 +1,24 @@ +import { inject, injectable } from "inversify"; + +import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; +import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; +import { type SolanaAppBinder } from "@internal/app-binder/SolanaAppBinder"; + +@injectable() +export class SignMessageUseCase { + private _appBinder: SolanaAppBinder; + + constructor( + @inject(appBinderTypes.AppBinder) + appBinding: SolanaAppBinder, + ) { + this._appBinder = appBinding; + } + + execute(derivationPath: string, message: string): SignMessageDAReturnType { + return this._appBinder.signMessage({ + derivationPath, + message, + }); + } +}