From 35706ec654f8cb6ad3ae5b765c065da9bd074f50 Mon Sep 17 00:00:00 2001 From: fAnselmi-Ledger Date: Wed, 30 Oct 2024 14:15:08 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(signer-solana):=20Add=20solana=20S?= =?UTF-8?q?ignDataTask=20and=20utils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fresh-walls-jam.md | 5 + .../SignOffChainMessageCommand.test.ts | 3 +- .../command/SignOffChainMessageCommand.ts | 18 +- .../__test-utils__/makeInternalApi.ts | 17 + .../task/SendCommandInChunksTask.ts | 63 +++ .../app-binder/task/SendSignDataTask.test.ts | 393 ++++++++++++++++++ .../app-binder/task/SendSignDataTask.ts | 47 +++ 7 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 .changeset/fresh-walls-jam.md create mode 100644 packages/signer/signer-solana/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts create mode 100644 packages/signer/signer-solana/src/internal/app-binder/task/SendCommandInChunksTask.ts create mode 100644 packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.test.ts create mode 100644 packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.ts diff --git a/.changeset/fresh-walls-jam.md b/.changeset/fresh-walls-jam.md new file mode 100644 index 000000000..12d0eb0b8 --- /dev/null +++ b/.changeset/fresh-walls-jam.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-solana": patch +--- + +Add solana SignDataTask and utils 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 aa49ee14e..a273082b8 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,6 +3,7 @@ import { ApduResponse, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; +import { Just } from "purify-ts"; import { SignOffChainMessageCommand } from "./SignOffChainMessageCommand"; @@ -50,7 +51,7 @@ describe("SignOffChainMessageCommand", () => { expect(isSuccessCommandResult(parsed)).toBe(true); if (isSuccessCommandResult(parsed)) { - expect(parsed.data).toEqual(signature); + expect(parsed.data).toEqual(Just(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 efdf776b5..db01df242 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,13 +10,15 @@ import { GlobalCommandErrorHandler, InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; +import { Just, type Maybe, Nothing } from "purify-ts"; -const SIGNATURE_LENGTH = 64; +import { type Signature } from "@api/model/Signature"; -type SignOffChainMessageCommandResponse = Uint8Array; +const SIGNATURE_LENGTH = 64; -type SignOffChainMessageCommandArgs = { - message: Uint8Array; +export type SignOffChainMessageCommandResponse = Maybe; +export type SignOffChainMessageCommandArgs = { + readonly message: Uint8Array; }; export class SignOffChainMessageCommand @@ -57,6 +59,12 @@ export class SignOffChainMessageCommand }); } + if (parser.getUnparsedRemainingLength() === 0) { + return CommandResultFactory({ + data: Nothing, + }); + } + const signature = parser.extractFieldByLength(SIGNATURE_LENGTH); if (!signature) { return CommandResultFactory({ @@ -65,7 +73,7 @@ export class SignOffChainMessageCommand } return CommandResultFactory({ - data: signature, + data: Just(signature), }); } } diff --git a/packages/signer/signer-solana/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts new file mode 100644 index 000000000..08104df33 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts @@ -0,0 +1,17 @@ +import { type InternalApi } from "@ledgerhq/device-management-kit"; + +const sendCommandMock = jest.fn(); +const apiGetDeviceSessionStateMock = jest.fn(); +const apiGetDeviceSessionStateObservableMock = jest.fn(); +const setDeviceSessionStateMock = jest.fn(); +const getMetadataForAppHashesMock = jest.fn(); + +export function makeDeviceActionInternalApiMock(): jest.Mocked { + return { + sendCommand: sendCommandMock, + getDeviceSessionState: apiGetDeviceSessionStateMock, + getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock, + setDeviceSessionState: setDeviceSessionStateMock, + getMetadataForAppHashes: getMetadataForAppHashesMock, + }; +} diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/SendCommandInChunksTask.ts b/packages/signer/signer-solana/src/internal/app-binder/task/SendCommandInChunksTask.ts new file mode 100644 index 000000000..b9da4ddf8 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/SendCommandInChunksTask.ts @@ -0,0 +1,63 @@ +import { + APDU_MAX_PAYLOAD, + ByteArrayBuilder, + type Command, + type CommandResult, + CommandResultFactory, + type InternalApi, + InvalidStatusWordError, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; + +export type SendCommandInChunksTaskArgs = { + data: Uint8Array; + commandFactory: CommandFactory; +}; + +export type CommandFactory = ( + args: ChunkableCommandArgs, +) => Command; + +export type ChunkableCommandArgs = { + chunkedData: Uint8Array; +}; + +export class SendCommandInChunksTask { + constructor( + private api: InternalApi, + private args: SendCommandInChunksTaskArgs, + ) {} + + async run(): Promise> { + const { data: fullPayload, commandFactory } = this.args; + + const dataBuffer = new ByteArrayBuilder(fullPayload.length) + .addBufferToData(fullPayload) + .build(); + + for ( + let offset = 0; + offset < dataBuffer.length; + offset += APDU_MAX_PAYLOAD + ) { + const isLastChunk = offset + APDU_MAX_PAYLOAD >= dataBuffer.length; + const result = await this.api.sendCommand( + commandFactory({ + chunkedData: dataBuffer.slice(offset, offset + APDU_MAX_PAYLOAD), + }), + ); + + if (!isSuccessCommandResult(result)) { + return result; + } + + if (isLastChunk) { + return CommandResultFactory({ + data: result.data, + }); + } + } + + throw new InvalidStatusWordError("No result after processing all chunks"); + } +} 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 new file mode 100644 index 000000000..049895223 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.test.ts @@ -0,0 +1,393 @@ +import { + APDU_MAX_PAYLOAD, + ByteArrayBuilder, + CommandResultFactory, + InvalidStatusWordError, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; +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"; + +const DERIVATION_PATH = "44'/501'/0'/0'"; +const PATH_SIZE = 4; + +describe("SignDataTask", () => { + const apiMock = makeDeviceActionInternalApiMock(); + const signature = new Uint8Array([ + 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, + ]); + const resultOk = CommandResultFactory({ data: Just(signature) }); + const resultNothing = CommandResultFactory({ data: Nothing }); + + beforeEach(() => { + 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)); + + 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, + ); + builder.add8BitUIntToData(paths.length); + paths.forEach((path) => builder.add32BitUIntToData(path)); + builder.addBufferToData(SIMPLE_TRANSACTION); + const EXPECTED_SIMPLE_TRANSACTION = builder.build(); + + const args = { + derivationPath: DERIVATION_PATH, + sendingData: SIMPLE_TRANSACTION, + commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => + new SignTransactionCommand({ + serializedTransaction: 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 SignTransactionCommand) + ?.args?.serializedTransaction || [], + ), + ).toEqual(Array.from(EXPECTED_SIMPLE_TRANSACTION)); + + 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 transaction in chunks", async () => { + // GIVEN------------------------------- + //------------------------------------- + const paths = DerivationPathUtils.splitPath(DERIVATION_PATH); + const builder = new ByteArrayBuilder( + BIG_TRANSACTION.length + 1 + paths.length * PATH_SIZE, + ); + builder.add8BitUIntToData(paths.length); + paths.forEach((path) => builder.add32BitUIntToData(path)); + builder.addBufferToData(BIG_TRANSACTION); + const dataBuffer = builder.build(); + + const EXPECTED_BIG_TRANSACTION_CHUNK_1 = dataBuffer.slice( + 0, + APDU_MAX_PAYLOAD, + ); + const EXPECTED_BIG_TRANSACTION_CHUNK_2 = + dataBuffer.slice(APDU_MAX_PAYLOAD); + + const args = { + derivationPath: DERIVATION_PATH, + sendingData: BIG_TRANSACTION, + commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => + new SignTransactionCommand({ + serializedTransaction: chunkArgs.chunkedData, + }), + }; + apiMock.sendCommand + .mockResolvedValueOnce(resultNothing) + .mockResolvedValueOnce(resultOk); + + // WHEN-------------------------------- + //------------------------------------- + const result = await new SignDataTask(apiMock, args).run(); + + // THEN-------------------------------- + //------------------------------------- + expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); + expect(apiMock.sendCommand).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + args: { serializedTransaction: EXPECTED_BIG_TRANSACTION_CHUNK_1 }, + }), + ); + expect(apiMock.sendCommand).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + args: { serializedTransaction: EXPECTED_BIG_TRANSACTION_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 paths = DerivationPathUtils.splitPath(DERIVATION_PATH); + const builder = new ByteArrayBuilder( + SIMPLE_TRANSACTION.length + 1 + paths.length * PATH_SIZE, + ); + builder.add8BitUIntToData(paths.length); + paths.forEach((path) => builder.add32BitUIntToData(path)); + builder.addBufferToData(SIMPLE_TRANSACTION); + + const args = { + derivationPath: DERIVATION_PATH, + sendingData: SIMPLE_TRANSACTION, + commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => + new SignTransactionCommand({ + serializedTransaction: 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 paths = DerivationPathUtils.splitPath(DERIVATION_PATH); + const builder = new ByteArrayBuilder( + BIG_TRANSACTION.length + 1 + paths.length * PATH_SIZE, + ); + builder.add8BitUIntToData(paths.length); + paths.forEach((path) => builder.add32BitUIntToData(path)); + builder.addBufferToData(BIG_TRANSACTION); + + const args = { + derivationPath: DERIVATION_PATH, + sendingData: BIG_TRANSACTION, + commandFactory: (chunkArgs: { chunkedData: Uint8Array }) => + new SignTransactionCommand({ + serializedTransaction: chunkArgs.chunkedData, + }), + }; + apiMock.sendCommand + .mockResolvedValueOnce(resultNothing) + .mockResolvedValueOnce( + CommandResultFactory({ + error: new InvalidStatusWordError("An error occurred"), + }), + ); + + // WHEN-------------------------------- + //------------------------------------- + const result = await new SignDataTask(apiMock, args).run(); + + // THEN-------------------------------- + //------------------------------------- + expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ + error: new InvalidStatusWordError("An error occurred"), + }); + }); + }); +}); 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 new file mode 100644 index 000000000..04789d32b --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/SendSignDataTask.ts @@ -0,0 +1,47 @@ +import { + ByteArrayBuilder, + type CommandResult, + type InternalApi, +} from "@ledgerhq/device-management-kit"; +import { DerivationPathUtils } from "@ledgerhq/signer-utils"; +import { type Maybe } from "purify-ts"; + +import { type Signature } from "@api/model/Signature"; + +import { + type CommandFactory, + SendCommandInChunksTask, +} from "./SendCommandInChunksTask"; + +const PATH_SIZE = 4; + +type SignDataTaskArgs = { + sendingData: Uint8Array; + derivationPath: string; + commandFactory: CommandFactory>; +}; + +export class SignDataTask { + constructor( + private api: InternalApi, + private args: SignDataTaskArgs, + ) {} + + async run(): Promise, void>> { + const { sendingData, derivationPath, commandFactory } = this.args; + + const paths = DerivationPathUtils.splitPath(derivationPath); + const builder = new ByteArrayBuilder( + sendingData.length + 1 + paths.length * PATH_SIZE, + ); + builder.add8BitUIntToData(paths.length); + paths.forEach((path) => builder.add32BitUIntToData(path)); + builder.addBufferToData(sendingData); + const buffer = builder.build(); + + return await new SendCommandInChunksTask>(this.api, { + data: buffer, + commandFactory, + }).run(); + } +}