diff --git a/.changeset/pink-hats-battle.md b/.changeset/pink-hats-battle.md new file mode 100644 index 000000000..94e044521 --- /dev/null +++ b/.changeset/pink-hats-battle.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-solana": minor +--- + +Add SignTransaction usecase diff --git a/.changeset/spicy-toes-ring.md b/.changeset/spicy-toes-ring.md new file mode 100644 index 000000000..ae5535879 --- /dev/null +++ b/.changeset/spicy-toes-ring.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit-sample": patch +--- + +Add signTransaction usecase for Solana signer diff --git a/apps/sample/src/components/SignerSolanaView/index.tsx b/apps/sample/src/components/SignerSolanaView/index.tsx index eab604be1..29a78a41c 100644 --- a/apps/sample/src/components/SignerSolanaView/index.tsx +++ b/apps/sample/src/components/SignerSolanaView/index.tsx @@ -1,4 +1,8 @@ import React, { useMemo } from "react"; +import { + base64StringToBuffer, + isBase64String, +} from "@ledgerhq/device-management-kit"; import { type GetAddressDAError, type GetAddressDAIntermediateValue, @@ -7,6 +11,9 @@ import { type GetAppConfigurationDAIntermediateValue, type GetAppConfigurationDAOutput, SignerSolanaBuilder, + type SignTransactionDAError, + type SignTransactionDAIntermediateValue, + type SignTransactionDAOutput, } from "@ledgerhq/device-signer-kit-solana"; import { DeviceActionsList } from "@/components/DeviceActionsView/DeviceActionsList"; @@ -51,6 +58,35 @@ export const SignerSolanaView: React.FC<{ sessionId: string }> = ({ GetAddressDAError, GetAddressDAIntermediateValue >, + { + title: "Sign Transaction", + description: + "Perform all the actions necessary to sign a Solana transaction with the device", + executeDeviceAction: ({ derivationPath, transaction }) => { + const serializedTransaction = + base64StringToBuffer(transaction) ?? new Uint8Array(); + return signer.signTransaction( + derivationPath, + serializedTransaction, + {}, + ); + }, + initialValues: { + derivationPath: DEFAULT_DERIVATION_PATH, + transaction: "", + }, + deviceModelId, + validateValues: ({ transaction }) => + isBase64String(transaction) && transaction.length > 0, + } satisfies DeviceActionProps< + SignTransactionDAOutput, + { + derivationPath: string; + transaction: string; + }, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >, { title: "Get app configuration", description: diff --git a/packages/signer/signer-solana/src/api/app-binder/SignTransactionDeviceActionTypes.ts b/packages/signer/signer-solana/src/api/app-binder/SignTransactionDeviceActionTypes.ts index 93c238a6c..84dfc0723 100644 --- a/packages/signer/signer-solana/src/api/app-binder/SignTransactionDeviceActionTypes.ts +++ b/packages/signer/signer-solana/src/api/app-binder/SignTransactionDeviceActionTypes.ts @@ -39,7 +39,6 @@ export type SignTransactionDAState = DeviceActionState< export type SignTransactionDAInternalState = { readonly error: SignTransactionDAError | null; - readonly serializedTransaction: Uint8Array | null; readonly signature: Signature | null; }; diff --git a/packages/signer/signer-solana/src/api/index.ts b/packages/signer/signer-solana/src/api/index.ts index c1ef0e6da..48e627bfa 100644 --- a/packages/signer/signer-solana/src/api/index.ts +++ b/packages/signer/signer-solana/src/api/index.ts @@ -9,5 +9,14 @@ export type { GetAppConfigurationDAIntermediateValue, GetAppConfigurationDAOutput, } from "@api/app-binder/GetAppConfigurationDeviceActionTypes"; +export type { + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAOutput, + SignTransactionDAReturnType, +} from "@api/app-binder/SignTransactionDeviceActionTypes"; +export type { Signature } from "@api/model/Signature"; +export type { Transaction } from "@api/model/Transaction"; +export type { TransactionOptions } from "@api/model/TransactionOptions"; export type { SignerSolana } from "@api/SignerSolana"; export { SignerSolanaBuilder } from "@api/SignerSolanaBuilder"; diff --git a/packages/signer/signer-solana/src/internal/DefaultSignerSolana.test.ts b/packages/signer/signer-solana/src/internal/DefaultSignerSolana.test.ts index dff5ccade..8d8010185 100644 --- a/packages/signer/signer-solana/src/internal/DefaultSignerSolana.test.ts +++ b/packages/signer/signer-solana/src/internal/DefaultSignerSolana.test.ts @@ -24,6 +24,16 @@ describe("DefaultSignerSolana", () => { expect(dmk.executeDeviceAction).toHaveBeenCalled(); }); + it("should call signTransaction", () => { + const dmk = { + executeDeviceAction: jest.fn(), + } as unknown as DeviceManagementKit; + const sessionId = {} as DeviceSessionId; + const signer = new DefaultSignerSolana({ dmk, sessionId }); + signer.signTransaction("derivationPath", new Uint8Array(), {}); + 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 ed86c8a09..e28e89c39 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 SignTransactionUseCase } from "./use-cases/transaction/SignTransactionUseCase"; import { makeContainer } from "./di"; export type DefaultSignerSolanaConstructorArgs = { @@ -31,11 +32,13 @@ export class DefaultSignerSolana implements SignerSolana { } signTransaction( - _derivationPath: string, - _transaction: Transaction, - _options?: TransactionOptions, + derivationPath: string, + transaction: Transaction, + options?: TransactionOptions, ): SignTransactionDAReturnType { - return {} as SignTransactionDAReturnType; + return this._container + .get(useCasesTypes.SignTransactionUseCase) + .execute(derivationPath, transaction, options); } signMessage( 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 4666e4995..892925953 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 @@ -17,9 +17,13 @@ import { type GetAddressDAError, type GetAddressDAIntermediateValue, type GetAddressDAOutput, + type SignTransactionDAError, + type SignTransactionDAIntermediateValue, + type SignTransactionDAOutput, } from "@api/index"; import { GetPubKeyCommand } from "./command/GetPubKeyCommand"; +import { SignTransactionDeviceAction } from "./device-action/SignTransactionDeviceAction"; import { SolanaAppBinder } from "./SolanaAppBinder"; describe("SolanaAppBinder", () => { @@ -153,6 +157,84 @@ describe("SolanaAppBinder", () => { }); }); + describe("signTransaction", () => { + it("should return the signature", (done) => { + // GIVEN + const signature = new Uint8Array([0x01, 0x02, 0x03]); + + jest.spyOn(mockedDmk, "executeDeviceAction").mockReturnValue({ + observable: from([ + { + status: DeviceActionStatus.Completed, + output: signature, + } as DeviceActionState< + SignTransactionDAOutput, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >, + ]), + cancel: jest.fn(), + }); + + // WHEN + const appBinder = new SolanaAppBinder(mockedDmk, "sessionId"); + const { observable } = appBinder.signTransaction({ + derivationPath: "44'/501'", + transaction: new Uint8Array([0x01, 0x02, 0x03, 0x04]), + }); + + // THEN + const states: DeviceActionState< + SignTransactionDAOutput, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >[] = []; + observable.subscribe({ + next: (state) => { + states.push(state); + }, + error: (err) => { + done(err); + }, + complete: () => { + try { + expect(states).toEqual([ + { + status: DeviceActionStatus.Completed, + output: signature, + }, + ]); + done(); + } catch (err) { + done(err); + } + }, + }); + }); + + it("should call executeDeviceAction with the correct params", () => { + // GIVEN + const derivationPath = "44'/60'/3'/2/1"; + const transaction = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + + // WHEN + const appBinder = new SolanaAppBinder(mockedDmk, "sessionId"); + appBinder.signTransaction({ derivationPath, transaction }); + + // THEN + expect(mockedDmk.executeDeviceAction).toHaveBeenCalledWith({ + deviceAction: new SignTransactionDeviceAction({ + input: { + derivationPath, + transaction, + options: {}, + }, + }), + sessionId: "sessionId", + }); + }); + }); + describe("getAppConfiguration", () => { it("should return the app configuration", (done) => { // GIVEN 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 800646b47..1fc29725a 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/SolanaAppBinder.ts @@ -11,10 +11,12 @@ import { GetAppConfigurationDAReturnType } from "@api/app-binder/GetAppConfigura import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; import { SignTransactionDAReturnType } from "@api/app-binder/SignTransactionDeviceActionTypes"; import { Transaction } from "@api/model/Transaction"; +import { TransactionOptions } from "@api/model/TransactionOptions"; import { externalTypes } from "@internal/externalTypes"; import { GetAppConfigurationCommand } from "./command/GetAppConfigurationCommand"; import { GetPubKeyCommand } from "./command/GetPubKeyCommand"; +import { SignTransactionDeviceAction } from "./device-action/SignTransactionDeviceAction"; @injectable() export class SolanaAppBinder { @@ -41,11 +43,21 @@ export class SolanaAppBinder { }); } - signTransaction(_args: { + signTransaction(args: { derivationPath: string; transaction: Transaction; + options?: TransactionOptions; }): SignTransactionDAReturnType { - return {} as SignTransactionDAReturnType; + return this.dmk.executeDeviceAction({ + sessionId: this.sessionId, + deviceAction: new SignTransactionDeviceAction({ + input: { + derivationPath: args.derivationPath, + transaction: args.transaction, + options: args.options ?? {}, + }, + }), + }); } signMessage(_args: { 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 0730a51f3..e5f09abc9 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 @@ -15,6 +15,8 @@ import { describe("SignTransactionCommand", () => { const defaultArgs: SignTransactionCommandArgs = { serializedTransaction: new Uint8Array(), + more: false, + extend: false, }; describe("getApdu", () => { @@ -37,6 +39,8 @@ describe("SignTransactionCommand", () => { // GIVEN const command = new SignTransactionCommand({ serializedTransaction: new Uint8Array([0x01, 0x02, 0x03]), + more: false, + extend: false, }); // WHEN @@ -49,6 +53,63 @@ describe("SignTransactionCommand", () => { expect(apdu.p1).toBe(0x01); expect(apdu.p2).toBe(0x00); }); + + it("should return the correct APDU when the more flag is set", () => { + // GIVEN + const command = new SignTransactionCommand({ + serializedTransaction: new Uint8Array([0x01, 0x02, 0x03]), + more: true, + extend: false, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.data).toStrictEqual(new Uint8Array([0x01, 0x02, 0x03])); + expect(apdu.cla).toBe(0xe0); + expect(apdu.ins).toBe(0x06); + expect(apdu.p1).toBe(0x01); + expect(apdu.p2).toBe(0x02); + }); + + it("should return the correct APDU when the extend flag is set", () => { + // GIVEN + const command = new SignTransactionCommand({ + serializedTransaction: new Uint8Array([0x01, 0x02, 0x03]), + more: false, + extend: true, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.data).toStrictEqual(new Uint8Array([0x01, 0x02, 0x03])); + expect(apdu.cla).toBe(0xe0); + expect(apdu.ins).toBe(0x06); + expect(apdu.p1).toBe(0x01); + expect(apdu.p2).toBe(0x01); + }); + + it("should return the correct APDU when the more and extend flags are set", () => { + // GIVEN + const command = new SignTransactionCommand({ + serializedTransaction: new Uint8Array([0x01, 0x02, 0x03]), + more: true, + extend: true, + }); + + // WHEN + const apdu = command.getApdu(); + + // THEN + expect(apdu.data).toStrictEqual(new Uint8Array([0x01, 0x02, 0x03])); + expect(apdu.cla).toBe(0xe0); + expect(apdu.ins).toBe(0x06); + expect(apdu.p1).toBe(0x01); + expect(apdu.p2).toBe(0x03); + }); }); describe("parseResponse", () => { diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/SignTransactionCommand.ts b/packages/signer/signer-solana/src/internal/app-binder/command/SignTransactionCommand.ts index 0a9cc9438..0523dffdc 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/command/SignTransactionCommand.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/command/SignTransactionCommand.ts @@ -23,6 +23,8 @@ export type SignTransactionCommandArgs = { * Chunked serialized transaction */ readonly serializedTransaction: Uint8Array; + readonly more: boolean; + readonly extend: boolean; }; export class SignTransactionCommand @@ -36,12 +38,16 @@ export class SignTransactionCommand } getApdu(): Apdu { - const { serializedTransaction } = this.args; + const { more, extend, serializedTransaction } = this.args; + let p2 = 0x00; + if (more) p2 |= 0x02; + if (extend) p2 |= 0x01; + const signTransactionArgs: ApduBuilderArgs = { cla: 0xe0, ins: 0x06, p1: 0x01, - p2: 0x00, + p2, }; return new ApduBuilder(signTransactionArgs) diff --git a/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.test.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.test.ts new file mode 100644 index 000000000..3e5dd9056 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.test.ts @@ -0,0 +1,319 @@ +import { + CommandResultFactory, + DeviceActionStatus, + InvalidStatusWordError, + UnknownDAError, + UserInteractionRequired, +} from "@ledgerhq/device-management-kit"; +import { Just, Nothing } from "purify-ts"; + +import { type SignTransactionDAState } from "@api/app-binder/SignTransactionDeviceActionTypes"; +import { type Transaction } from "@api/model/Transaction"; +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 { SignTransactionDeviceAction } from "./SignTransactionDeviceAction"; + +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("SignTransactionDeviceAction", () => { + const signTransactionMock = jest.fn(); + function extractDependenciesMock() { + return { + signTransaction: signTransactionMock, + }; + } + const defaultOptions = {}; + let defaultTransaction: Transaction; + + beforeEach(() => { + jest.clearAllMocks(); + defaultTransaction = new Uint8Array([0x01, 0x02, 0x03]); + }); + + describe("Happy path", () => { + it("should call external dependencies with the correct parameters", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/501'/0'/0'", + transaction: defaultTransaction, + options: defaultOptions, + }, + }); + + // Mock the dependencies to return some sample data + signTransactionMock.mockResolvedValueOnce( + CommandResultFactory({ + data: Just(new Uint8Array([0x05, 0x06, 0x07])), + }), + ); + 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, + }, + // SignTransaction state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + status: DeviceActionStatus.Pending, + }, + // Final state + { + output: new Uint8Array([0x05, 0x06, 0x07]), + status: DeviceActionStatus.Completed, + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + + // Verify mocks calls parameters + observable.subscribe({ + complete: () => { + expect(signTransactionMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + derivationPath: "44'/501'/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'/501'/0'/0'", + transaction: defaultTransaction, + options: defaultOptions, + }, + }); + + 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("SignTransaction errors", () => { + it("should fail if signTransaction returns an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/501'/0'/0'", + transaction: defaultTransaction, + options: defaultOptions, + }, + }); + + 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, + }, + // SignTransaction state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + status: DeviceActionStatus.Pending, + }, + // SignTransaction error + { + error: new InvalidStatusWordError("signTransaction error"), + status: DeviceActionStatus.Error, + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should fail if signTransaction returns nothing", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/501'/0'/0'", + transaction: defaultTransaction, + options: defaultOptions, + }, + }); + + signTransactionMock.mockResolvedValueOnce( + CommandResultFactory({ + data: Nothing, + }), + ); + 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, + }, + // SignTransaction state + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + status: DeviceActionStatus.Pending, + }, + // SignTransaction error + { + error: new UnknownDAError("No Signature available"), + status: DeviceActionStatus.Error, + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); + + describe("extractDependencies", () => { + it("should extract dependencies", async () => { + const deviceAction = new SignTransactionDeviceAction({ + input: { + derivationPath: "44'/501'/0'/0'", + transaction: defaultTransaction, + options: defaultOptions, + }, + }); + // mock sendCommand to return a successful result + const api = makeDeviceActionInternalApiMock(); + jest.spyOn(api, "sendCommand").mockResolvedValue( + CommandResultFactory({ + data: Just(new Uint8Array([0x05, 0x06, 0x07])), + }), + ); + + const dependencies = deviceAction.extractDependencies( + makeDeviceActionInternalApiMock(), + ); + const signature = await dependencies.signTransaction({ + input: { + derivationPath: "44'/501'/0'/0'", + serializedTransaction: defaultTransaction, + }, + }); + + expect(dependencies.signTransaction).toBeInstanceOf(Function); + expect(signature).toEqual( + CommandResultFactory({ + data: Just(new Uint8Array([0x05, 0x06, 0x07])), + }), + ); + expect(api.sendCommand).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.ts new file mode 100644 index 000000000..0d695eac2 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.ts @@ -0,0 +1,237 @@ +import { + type CommandResult, + type DeviceActionStateMachine, + type InternalApi, + isSuccessCommandResult, + OpenAppDeviceAction, + type StateMachineTypes, + UnknownDAError, + UserInteractionRequired, + XStateDeviceAction, +} from "@ledgerhq/device-management-kit"; +import { Left, type Maybe, Right } from "purify-ts"; +import { assign, fromPromise, setup } from "xstate"; + +import { + type SignTransactionDAError, + type SignTransactionDAInput, + type SignTransactionDAIntermediateValue, + type SignTransactionDAInternalState, + type SignTransactionDAOutput, +} from "@api/app-binder/SignTransactionDeviceActionTypes"; +import { type Signature } from "@api/model/Signature"; +import { SignTransactionCommand } from "@internal/app-binder/command/SignTransactionCommand"; +import { SignDataTask } from "@internal/app-binder/task/SendSignDataTask"; + +export type MachineDependencies = { + readonly signTransaction: (arg0: { + input: { + derivationPath: string; + serializedTransaction: Uint8Array; + }; + }) => Promise>>; +}; + +export class SignTransactionDeviceAction extends XStateDeviceAction< + SignTransactionDAOutput, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAInternalState +> { + makeStateMachine( + internalApi: InternalApi, + ): DeviceActionStateMachine< + SignTransactionDAOutput, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAInternalState + > { + type types = StateMachineTypes< + SignTransactionDAOutput, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAInternalState + >; + + const { 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: "Solana" }, + }).makeStateMachine(internalApi), + 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, + signature: null, + }, + }; + }, + states: { + OpenAppDeviceAction: { + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "openAppStateMachine", + input: { appName: "Solana" }, + 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: "SignTransaction", + guard: "noInternalError", + }, + "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.input.transaction, + }), + onDone: { + target: "SignTransactionResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (!isSuccessCommandResult(event.output)) + return { + ...context._internalState, + error: event.output.error, + }; + + if (event.output.data.isJust()) + return { + ...context._internalState, + signature: event.output.data.extract(), + }; + + return { + ...context._internalState, + error: new UnknownDAError("No Signature available"), + }; + }, + }), + ], + }, + 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 or signature available"), + ), + }); + } + + extractDependencies(internalApi: InternalApi): MachineDependencies { + const signTransaction = async (arg0: { + input: { + derivationPath: string; + serializedTransaction: Uint8Array; + }; + }) => { + return new SignDataTask(internalApi, { + commandFactory: (args) => + new SignTransactionCommand({ + serializedTransaction: args.chunkedData, + more: args.more, + extend: args.extend, + }), + derivationPath: arg0.input.derivationPath, + sendingData: arg0.input.serializedTransaction, + }).run(); + }; + + return { + signTransaction, + }; + } +} diff --git a/packages/signer/signer-solana/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts new file mode 100644 index 000000000..3d27aabf5 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts @@ -0,0 +1,32 @@ +import { + OpenAppDeviceAction, + UserInteractionRequired, +} from "@ledgerhq/device-management-kit"; +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/signer-solana/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts new file mode 100644 index 000000000..bc8f1ce01 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts @@ -0,0 +1,48 @@ +import { + type DeviceAction, + type DeviceActionIntermediateValue, + type DeviceActionState, + type DmkError, + type InternalApi, +} from "@ledgerhq/device-management-kit"; + +/** + * Test that the states emitted by a device action match the expected states. + * @param deviceAction The device action to test. + * @param expectedStates The expected states. + * @param done The Jest done callback. + */ +export function testDeviceActionStates< + Output, + Input, + Error extends DmkError, + IntermediateValue extends DeviceActionIntermediateValue, +>( + deviceAction: DeviceAction, + expectedStates: Array>, + internalApi: InternalApi, + done?: jest.DoneCallback, +) { + const observedStates: Array< + DeviceActionState + > = []; + + const { observable, cancel } = deviceAction._execute(internalApi); + observable.subscribe({ + next: (state) => { + observedStates.push(state); + }, + error: (error) => { + if (done) done(error); + }, + complete: () => { + try { + expect(observedStates).toEqual(expectedStates); + if (done) done(); + } catch (e) { + if (done) done(e); + } + }, + }); + return { observable, cancel }; +} diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/SendCommandInChunksTask.ts b/packages/signer/signer-solana/src/internal/app-binder/task/SendCommandInChunksTask.ts index b9da4ddf8..9710940a7 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/SendCommandInChunksTask.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/SendCommandInChunksTask.ts @@ -20,6 +20,8 @@ export type CommandFactory = ( export type ChunkableCommandArgs = { chunkedData: Uint8Array; + more: boolean; + extend: boolean; }; export class SendCommandInChunksTask { @@ -43,6 +45,8 @@ export class SendCommandInChunksTask { const isLastChunk = offset + APDU_MAX_PAYLOAD >= dataBuffer.length; const result = await this.api.sendCommand( commandFactory({ + more: !isLastChunk, + extend: offset > 0, chunkedData: dataBuffer.slice(offset, offset + APDU_MAX_PAYLOAD), }), ); diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.test.ts index 049895223..5cc0b6c15 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.test.ts @@ -8,11 +8,12 @@ import { import { DerivationPathUtils } from "@ledgerhq/signer-utils"; import { Just, Nothing } from "purify-ts"; -import { SignOffChainMessageCommand } from "@internal/app-binder/command/SignOffChainMessageCommand"; import { SignTransactionCommand } from "@internal/app-binder/command/SignTransactionCommand"; import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; import { SignDataTask } from "@internal/app-binder/task/SendSignDataTask"; +import { type ChunkableCommandArgs } from "./SendCommandInChunksTask"; + const DERIVATION_PATH = "44'/501'/0'/0'"; const PATH_SIZE = 4; @@ -28,198 +29,18 @@ describe("SignDataTask", () => { jest.resetAllMocks(); }); - describe("run with SignOffChainMessageCommand", () => { - const SIMPLE_MESSAGE = new Uint8Array([0x01, 0x02, 0x03, 0x04]); - const EXPECTED_SIMPLE_MESSAGE = new Uint8Array([ - 0x04, - // first path element: 44' => 0x8000002C - 0x80, - 0x00, - 0x00, - 0x2c, - // second path element: 501' => 0x800001F5 - 0x80, - 0x00, - 0x01, - 0xf5, - // third path element: 0' => 0x80000000 - 0x80, - 0x00, - 0x00, - 0x00, - // fourth path element: 0' => 0x80000000 - 0x80, - 0x00, - 0x00, - 0x00, - // message - ...SIMPLE_MESSAGE, - ]); - const BIG_MESSAGE = new Uint8Array(new Array(345).fill(0x01)); - - it("should send the message in a single command", async () => { - // GIVEN------------------------------- - //------------------------------------- - const args = { - derivationPath: DERIVATION_PATH, - sendingData: SIMPLE_MESSAGE, - commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => - new SignOffChainMessageCommand({ - message: chunkArgs.chunkedData, - }), - }; - apiMock.sendCommand.mockResolvedValueOnce(resultOk); - - // WHEN-------------------------------- - //------------------------------------- - const result = await new SignDataTask(apiMock, args).run(); - - // THEN-------------------------------- - //------------------------------------- - expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); - expect( - Array.from( - (apiMock.sendCommand.mock.calls[0]?.[0] as SignOffChainMessageCommand) - ?.args?.message || [], - ), - ).toEqual(Array.from(EXPECTED_SIMPLE_MESSAGE)); - - if (isSuccessCommandResult(result)) { - expect(result.data).toEqual(Just(signature)); - } else { - fail(`Expected a successful result, but got an error: ${result.error}`); - } - }); - - it("should send the message in chunks", async () => { - // GIVEN------------------------------- - //------------------------------------- - const args = { - derivationPath: DERIVATION_PATH, - sendingData: BIG_MESSAGE, - commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => - new SignOffChainMessageCommand({ - message: chunkArgs.chunkedData, - }), - }; - apiMock.sendCommand - .mockResolvedValueOnce(resultNothing) - .mockResolvedValueOnce(resultOk); - - const paths = DerivationPathUtils.splitPath(DERIVATION_PATH); - const builder = new ByteArrayBuilder( - BIG_MESSAGE.length + 1 + paths.length * PATH_SIZE, - ); - builder.add8BitUIntToData(paths.length); - paths.forEach((path) => builder.add32BitUIntToData(path)); - builder.addBufferToData(BIG_MESSAGE); - const dataBuffer = builder.build(); - - const EXPECTED_BIG_MESSAGE_CHUNK_1 = dataBuffer.slice( - 0, - APDU_MAX_PAYLOAD, - ); - const EXPECTED_BIG_MESSAGE_CHUNK_2 = dataBuffer.slice(APDU_MAX_PAYLOAD); - - // WHEN-------------------------------- - //------------------------------------- - const result = await new SignDataTask(apiMock, args).run(); - - // THEN-------------------------------- - //------------------------------------- - expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); - expect(apiMock.sendCommand).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - args: { message: EXPECTED_BIG_MESSAGE_CHUNK_1 }, - }), - ); - expect(apiMock.sendCommand).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - args: { message: EXPECTED_BIG_MESSAGE_CHUNK_2 }, - }), - ); - - if (isSuccessCommandResult(result)) { - expect(result.data).toEqual(Just(signature)); - } else { - fail(`Expected a successful result, but got an error: ${result.error}`); - } - }); - - it("should return an error if the command fails", async () => { - // GIVEN------------------------------- - //------------------------------------- - const args = { - derivationPath: DERIVATION_PATH, - sendingData: SIMPLE_MESSAGE, - commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => - new SignOffChainMessageCommand({ - message: chunkArgs.chunkedData, - }), - }; - apiMock.sendCommand.mockResolvedValueOnce( - CommandResultFactory({ - error: new InvalidStatusWordError("no signature returned"), - }), - ); - - // WHEN-------------------------------- - //------------------------------------- - const result = await new SignDataTask(apiMock, args).run(); - - // THEN-------------------------------- - //------------------------------------- - expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); - expect(result).toMatchObject({ - error: new InvalidStatusWordError("no signature returned"), - }); - }); - - it("should return an error if a chunk command fails", async () => { - // GIVEN------------------------------- - //------------------------------------- - const args = { - derivationPath: DERIVATION_PATH, - sendingData: BIG_MESSAGE, - commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => - new SignOffChainMessageCommand({ - message: chunkArgs.chunkedData, - }), - }; - apiMock.sendCommand - .mockResolvedValueOnce(resultNothing) - .mockResolvedValueOnce( - CommandResultFactory({ - error: new InvalidStatusWordError("An error"), - }), - ); - - // WHEN-------------------------------- - //------------------------------------- - const result = await new SignDataTask(apiMock, args).run(); - - // THEN-------------------------------- - //------------------------------------- - expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); - expect(result).toMatchObject({ - error: new InvalidStatusWordError("An error"), - }); - }); - }); - describe("run with SignTransactionCommand", () => { const SIMPLE_TRANSACTION = new Uint8Array([0x01, 0x02, 0x03, 0x04]); - const BIG_TRANSACTION = new Uint8Array(new Array(345).fill(0x01)); + const BIG_TRANSACTION = new Uint8Array(new Array(500).fill(0x01)); it("should send the transaction in a single command", async () => { // GIVEN------------------------------- //------------------------------------- const paths = DerivationPathUtils.splitPath(DERIVATION_PATH); const builder = new ByteArrayBuilder( - SIMPLE_TRANSACTION.length + 1 + paths.length * PATH_SIZE, + SIMPLE_TRANSACTION.length + 2 + paths.length * PATH_SIZE, ); + builder.add8BitUIntToData(1); builder.add8BitUIntToData(paths.length); paths.forEach((path) => builder.add32BitUIntToData(path)); builder.addBufferToData(SIMPLE_TRANSACTION); @@ -228,9 +49,11 @@ describe("SignDataTask", () => { const args = { derivationPath: DERIVATION_PATH, sendingData: SIMPLE_TRANSACTION, - commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => + commandFactory: (chunkArgs: ChunkableCommandArgs) => new SignTransactionCommand({ serializedTransaction: chunkArgs.chunkedData, + more: chunkArgs.more, + extend: chunkArgs.extend, }), }; apiMock.sendCommand.mockResolvedValueOnce(resultOk); @@ -261,8 +84,9 @@ describe("SignDataTask", () => { //------------------------------------- const paths = DerivationPathUtils.splitPath(DERIVATION_PATH); const builder = new ByteArrayBuilder( - BIG_TRANSACTION.length + 1 + paths.length * PATH_SIZE, + BIG_TRANSACTION.length + 2 + paths.length * PATH_SIZE, ); + builder.add8BitUIntToData(1); builder.add8BitUIntToData(paths.length); paths.forEach((path) => builder.add32BitUIntToData(path)); builder.addBufferToData(BIG_TRANSACTION); @@ -272,18 +96,27 @@ describe("SignDataTask", () => { 0, APDU_MAX_PAYLOAD, ); - const EXPECTED_BIG_TRANSACTION_CHUNK_2 = - dataBuffer.slice(APDU_MAX_PAYLOAD); + const EXPECTED_BIG_TRANSACTION_CHUNK_2 = dataBuffer.slice( + APDU_MAX_PAYLOAD, + APDU_MAX_PAYLOAD * 2, + ); + const EXPECTED_BIG_TRANSACTION_CHUNK_3 = dataBuffer.slice( + APDU_MAX_PAYLOAD * 2, + APDU_MAX_PAYLOAD * 3, + ); const args = { derivationPath: DERIVATION_PATH, sendingData: BIG_TRANSACTION, - commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => + commandFactory: (chunkArgs: ChunkableCommandArgs) => new SignTransactionCommand({ serializedTransaction: chunkArgs.chunkedData, + more: chunkArgs.more, + extend: chunkArgs.extend, }), }; apiMock.sendCommand + .mockResolvedValueOnce(resultNothing) .mockResolvedValueOnce(resultNothing) .mockResolvedValueOnce(resultOk); @@ -293,17 +126,36 @@ describe("SignDataTask", () => { // THEN-------------------------------- //------------------------------------- - expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); + expect(apiMock.sendCommand).toHaveBeenCalledTimes(3); expect(apiMock.sendCommand).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - args: { serializedTransaction: EXPECTED_BIG_TRANSACTION_CHUNK_1 }, + args: { + serializedTransaction: EXPECTED_BIG_TRANSACTION_CHUNK_1, + extend: false, + more: true, + }, }), ); expect(apiMock.sendCommand).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - args: { serializedTransaction: EXPECTED_BIG_TRANSACTION_CHUNK_2 }, + args: { + serializedTransaction: EXPECTED_BIG_TRANSACTION_CHUNK_2, + extend: true, + more: true, + }, + }), + ); + + expect(apiMock.sendCommand).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + args: { + serializedTransaction: EXPECTED_BIG_TRANSACTION_CHUNK_3, + extend: true, + more: false, + }, }), ); @@ -319,8 +171,9 @@ describe("SignDataTask", () => { //------------------------------------- const paths = DerivationPathUtils.splitPath(DERIVATION_PATH); const builder = new ByteArrayBuilder( - SIMPLE_TRANSACTION.length + 1 + paths.length * PATH_SIZE, + SIMPLE_TRANSACTION.length + 2 + paths.length * PATH_SIZE, ); + builder.add8BitUIntToData(1); builder.add8BitUIntToData(paths.length); paths.forEach((path) => builder.add32BitUIntToData(path)); builder.addBufferToData(SIMPLE_TRANSACTION); @@ -328,9 +181,11 @@ describe("SignDataTask", () => { const args = { derivationPath: DERIVATION_PATH, sendingData: SIMPLE_TRANSACTION, - commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => + commandFactory: (chunkArgs: ChunkableCommandArgs) => new SignTransactionCommand({ serializedTransaction: chunkArgs.chunkedData, + more: chunkArgs.more, + extend: chunkArgs.extend, }), }; apiMock.sendCommand.mockResolvedValueOnce( @@ -356,8 +211,9 @@ describe("SignDataTask", () => { //------------------------------------- const paths = DerivationPathUtils.splitPath(DERIVATION_PATH); const builder = new ByteArrayBuilder( - BIG_TRANSACTION.length + 1 + paths.length * PATH_SIZE, + BIG_TRANSACTION.length + 2 + paths.length * PATH_SIZE, ); + builder.add8BitUIntToData(1); builder.add8BitUIntToData(paths.length); paths.forEach((path) => builder.add32BitUIntToData(path)); builder.addBufferToData(BIG_TRANSACTION); @@ -365,9 +221,11 @@ describe("SignDataTask", () => { const args = { derivationPath: DERIVATION_PATH, sendingData: BIG_TRANSACTION, - commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => + commandFactory: (chunkArgs: ChunkableCommandArgs) => new SignTransactionCommand({ serializedTransaction: chunkArgs.chunkedData, + more: chunkArgs.more, + extend: chunkArgs.extend, }), }; apiMock.sendCommand diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.ts b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.ts index 04789d32b..2ecd1791f 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.ts @@ -32,9 +32,13 @@ export class SignDataTask { const paths = DerivationPathUtils.splitPath(derivationPath); const builder = new ByteArrayBuilder( - sendingData.length + 1 + paths.length * PATH_SIZE, + sendingData.length + 2 + paths.length * PATH_SIZE, ); + // add the number of signers + builder.add8BitUIntToData(1); + // add the number of derivation builder.add8BitUIntToData(paths.length); + // add every derivation path paths.forEach((path) => builder.add32BitUIntToData(path)); builder.addBufferToData(sendingData); const buffer = 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 21dc3dc46..06becdafd 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 @@ -26,5 +26,11 @@ describe("useCasesModuleFactory", () => { container.isBound(useCasesTypes.GetAppConfigurationUseCase), ).toBeTruthy(); }); + + it("should bind SignTransactionUseCase", () => { + expect( + container.isBound(useCasesTypes.SignTransactionUseCase), + ).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 1dccaa21c..9374fbc75 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 { SignTransactionUseCase } from "@internal/use-cases/transaction/SignTransactionUseCase"; export const useCasesModuleFactory = () => new ContainerModule( @@ -19,5 +20,6 @@ export const useCasesModuleFactory = () => bind(useCasesTypes.GetAppConfigurationUseCase).to( GetAppConfigurationUseCase, ); + bind(useCasesTypes.SignTransactionUseCase).to(SignTransactionUseCase); }, ); 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 8dcc01c55..64e4a432a 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 @@ -1,4 +1,5 @@ export const useCasesTypes = { GetAddressUseCase: Symbol.for("GetAddressUseCase"), GetAppConfigurationUseCase: Symbol.for("GetAppConfigurationUseCase"), + SignTransactionUseCase: Symbol.for("SignTransactionUseCase"), }; diff --git a/packages/signer/signer-solana/src/internal/use-cases/transaction/.gitkeep b/packages/signer/signer-solana/src/internal/use-cases/transaction/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/signer/signer-solana/src/internal/use-cases/transaction/SignTransactionUseCase.test.ts b/packages/signer/signer-solana/src/internal/use-cases/transaction/SignTransactionUseCase.test.ts new file mode 100644 index 000000000..5e780c43a --- /dev/null +++ b/packages/signer/signer-solana/src/internal/use-cases/transaction/SignTransactionUseCase.test.ts @@ -0,0 +1,35 @@ +import { type SolanaAppBinder } from "@internal/app-binder/SolanaAppBinder"; + +import { SignTransactionUseCase } from "./SignTransactionUseCase"; + +describe("GetAppConfigurationUseCase", () => { + const signTransactionMock = jest.fn(); + const appBinderMock = { + signTransaction: signTransactionMock, + } as unknown as SolanaAppBinder; + let useCase: SignTransactionUseCase; + + beforeEach(() => { + jest.clearAllMocks(); + useCase = new SignTransactionUseCase(appBinderMock); + }); + + it("should return the config from the appBinder's getAppConfiguration method", () => { + // GIVEN + signTransactionMock.mockReturnValue(new Uint8Array([0x042])); + + // WHEN + const result = useCase.execute( + "44'/501'/0'/0'", + new Uint8Array([0x01, 0x02, 0x03, 0x04]), + ); + + // THEN + expect(signTransactionMock).toHaveBeenCalledWith({ + derivationPath: "44'/501'/0'/0'", + transaction: new Uint8Array([0x01, 0x02, 0x03, 0x04]), + options: undefined, + }); + expect(result).toEqual(new Uint8Array([0x042])); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/use-cases/transaction/SignTransactionUseCase.ts b/packages/signer/signer-solana/src/internal/use-cases/transaction/SignTransactionUseCase.ts new file mode 100644 index 000000000..7dd7e25a8 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/use-cases/transaction/SignTransactionUseCase.ts @@ -0,0 +1,26 @@ +import { inject, injectable } from "inversify"; + +import { SignTransactionDAReturnType } from "@api/app-binder/SignTransactionDeviceActionTypes"; +import { Transaction } from "@api/model/Transaction"; +import { TransactionOptions } from "@api/model/TransactionOptions"; +import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; +import { SolanaAppBinder } from "@internal/app-binder/SolanaAppBinder"; + +@injectable() +export class SignTransactionUseCase { + constructor( + @inject(appBinderTypes.AppBinder) private appBinder: SolanaAppBinder, + ) {} + + execute( + derivationPath: string, + transaction: Transaction, + options?: TransactionOptions, + ): SignTransactionDAReturnType { + return this.appBinder.signTransaction({ + derivationPath, + transaction, + options, + }); + } +}