Skip to content

Commit

Permalink
✨ (signer-solana): Add solana SignDataTask and utils
Browse files Browse the repository at this point in the history
  • Loading branch information
fAnselmi-Ledger committed Oct 30, 2024
1 parent f3d03c8 commit 754f2e7
Show file tree
Hide file tree
Showing 5 changed files with 351 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-walls-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/device-signer-kit-solana": patch
---

Add solana SignDataTask and utils
Original file line number Diff line number Diff line change
@@ -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<InternalApi> {
return {
sendCommand: sendCommandMock,
getDeviceSessionState: apiGetDeviceSessionStateMock,
getDeviceSessionStateObservable: apiGetDeviceSessionStateObservableMock,
setDeviceSessionState: setDeviceSessionStateMock,
getMetadataForAppHashes: getMetadataForAppHashesMock,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
APDU_MAX_PAYLOAD,
ByteArrayBuilder,
type Command,
type CommandResult,
CommandResultFactory,
type InternalApi,
InvalidStatusWordError,
isSuccessCommandResult,
} from "@ledgerhq/device-management-kit";

export type SendCommandInChunksTaskArgs<T> = {
data: Uint8Array;
commandFactory: CommandFactory<T>;
};

export type CommandFactory<T> = <
V extends ChunkableCommandArgs & Record<string, unknown>,
>(
args: ChunkableCommandArgs,
) => Command<T, V>;

export type ChunkableCommandArgs = {
chunkedData: Uint8Array;
};

export class SendCommandInChunksTask<T> {
constructor(
private api: InternalApi,
private args: SendCommandInChunksTaskArgs<T>,
) {}

async run(): Promise<CommandResult<T, void>> {
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");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
ByteArrayBuilder,
type CommandResult,
CommandResultFactory,
type InternalApi,
isSuccessCommandResult,
} from "@ledgerhq/device-management-kit";
import { DerivationPathUtils } from "@ledgerhq/signer-utils";

import {
CommandFactory,
SendCommandInChunksTask,
} from "./SendCommandInChunksTask";

const PATH_SIZE = 4;

type SignDataTaskArgs<T> = {
sendingData: Uint8Array;
derivationPath: string;
commandFactory: CommandFactory<T>;
};

export class SignDataTask<T> {
constructor(
private api: InternalApi,
private args: SignDataTaskArgs<T>,
) {}

async run(): Promise<CommandResult<Uint8Array, 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();

const result = await new SendCommandInChunksTask<T>(this.api, {
data: buffer,
commandFactory: (args) => commandFactory(args),
}).run();

if (!isSuccessCommandResult(result)) {
return result;
}

return CommandResultFactory<Uint8Array, void>({
data: result.data as Uint8Array,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
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 { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi";
import { SignDataTask } from "@internal/app-binder/task/SignDataTask";

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"),
});
});
});
});

0 comments on commit 754f2e7

Please sign in to comment.