diff --git a/.changeset/slimy-toes-march.md b/.changeset/slimy-toes-march.md
index adf97840e..7ba239e62 100644
--- a/.changeset/slimy-toes-march.md
+++ b/.changeset/slimy-toes-march.md
@@ -2,4 +2,4 @@
"@ledgerhq/device-signer-kit-bitcoin": minor
---
-Implement ClientCommands
+Implement SignMessage
diff --git a/apps/sample/package.json b/apps/sample/package.json
index 92c849a0d..bdd162fd5 100644
--- a/apps/sample/package.json
+++ b/apps/sample/package.json
@@ -21,6 +21,7 @@
"@ledgerhq/device-mockserver-client": "workspace:*",
"@ledgerhq/device-signer-kit-ethereum": "workspace:*",
"@ledgerhq/device-signer-kit-solana": "workspace:*",
+ "@ledgerhq/device-signer-kit-bitcoin": "workspace:*",
"@ledgerhq/device-transport-kit-mockserver": "workspace:*",
"@ledgerhq/device-transport-kit-web-ble": "workspace:*",
"@ledgerhq/device-transport-kit-web-hid": "workspace:*",
diff --git a/apps/sample/src/app/signer/bitcoin/page.tsx b/apps/sample/src/app/signer/bitcoin/page.tsx
new file mode 100644
index 000000000..be89491a2
--- /dev/null
+++ b/apps/sample/src/app/signer/bitcoin/page.tsx
@@ -0,0 +1,11 @@
+"use client";
+import React from "react";
+
+import { SessionIdWrapper } from "@/components/SessionIdWrapper";
+import { SignerBitcoinView } from "@/components/SignerBtcView";
+
+const Signer: React.FC = () => {
+ return ;
+};
+
+export default Signer;
diff --git a/apps/sample/src/components/SignerBtcView/index.tsx b/apps/sample/src/components/SignerBtcView/index.tsx
new file mode 100644
index 000000000..4b50dc4ec
--- /dev/null
+++ b/apps/sample/src/components/SignerBtcView/index.tsx
@@ -0,0 +1,59 @@
+import React, { useMemo } from "react";
+import {
+ SignerBtcBuilder,
+ type SignMessageDAError,
+ type SignMessageDAIntermediateValue,
+ type SignMessageDAOutput,
+} from "@ledgerhq/device-signer-kit-bitcoin";
+
+import { DeviceActionsList } from "@/components/DeviceActionsView/DeviceActionsList";
+import { type DeviceActionProps } from "@/components/DeviceActionsView/DeviceActionTester";
+import { useDmk } from "@/providers/DeviceManagementKitProvider";
+
+const DEFAULT_DERIVATION_PATH = "44'/501'/0'/0'";
+
+export const SignerBitcoinView: React.FC<{ sessionId: string }> = ({
+ sessionId,
+}) => {
+ const dmk = useDmk();
+ const signer = new SignerBtcBuilder({ dmk, sessionId }).build();
+
+ const deviceModelId = dmk.getConnectedDevice({
+ sessionId,
+ }).modelId;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const deviceActions: DeviceActionProps[] = useMemo(
+ () => [
+ {
+ title: "Sign message",
+ description:
+ "Perform all the actions necessary to sign a message with 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
+ >,
+ ],
+ [deviceModelId, signer],
+ );
+
+ return (
+
+ );
+};
diff --git a/packages/signer/signer-btc/src/api/SignerBtc.ts b/packages/signer/signer-btc/src/api/SignerBtc.ts
index 64e25d512..1bc3ee428 100644
--- a/packages/signer/signer-btc/src/api/SignerBtc.ts
+++ b/packages/signer/signer-btc/src/api/SignerBtc.ts
@@ -3,15 +3,21 @@
// import { type Signature } from "@api/model/Signature";
// import { type Wallet } from "@api/model/Wallet";
import { type AddressOptions } from "@api/model/AddressOptions";
-import { type GetExtendedPublicKeyReturnType } from "@root/src";
+import {
+ type GetExtendedPublicKeyReturnType,
+ type SignMessageDAReturnType,
+} from "@root/src";
export interface SignerBtc {
getExtendedPublicKey: (
derivationPath: string,
options: AddressOptions,
) => GetExtendedPublicKeyReturnType;
+ signMessage: (
+ derivationPath: string,
+ message: string,
+ ) => SignMessageDAReturnType;
// getAddress: (wallet: Wallet, options?: AddressOptions) => Promise;
- // signMessage: (wallet: Wallet, message: string) => Promise;
// signPsbt: (wallet: Wallet, psbt: Psbt) => Promise;
// signTransaction: (wallet: Wallet, psbt: Psbt) => Promise;
}
diff --git a/packages/signer/signer-btc/src/api/SignerBtcBuilder.ts b/packages/signer/signer-btc/src/api/SignerBtcBuilder.ts
new file mode 100644
index 000000000..412334ac5
--- /dev/null
+++ b/packages/signer/signer-btc/src/api/SignerBtcBuilder.ts
@@ -0,0 +1,42 @@
+import {
+ type DeviceManagementKit,
+ type DeviceSessionId,
+} from "@ledgerhq/device-management-kit";
+
+import { DefaultSignerBtc } from "@internal/DefaultSignerBtc";
+
+type SignerBtcBuilderConstructorArgs = {
+ dmk: DeviceManagementKit;
+ sessionId: DeviceSessionId;
+};
+
+/**
+ * Builder for the `SignerBtc` class.
+ *
+ * @example
+ * ```
+ * const signer = new SignerBtcBuilder({ dmk, sessionId })
+ * .build();
+ * ```
+ */
+export class SignerBtcBuilder {
+ private _dmk: DeviceManagementKit;
+ private _sessionId: DeviceSessionId;
+
+ constructor({ dmk, sessionId }: SignerBtcBuilderConstructorArgs) {
+ this._dmk = dmk;
+ this._sessionId = sessionId;
+ }
+
+ /**
+ * Build the btc signer
+ *
+ * @returns the bitcoin signer
+ */
+ public build() {
+ return new DefaultSignerBtc({
+ dmk: this._dmk,
+ sessionId: this._sessionId,
+ });
+ }
+}
diff --git a/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts b/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts
new file mode 100644
index 000000000..289334468
--- /dev/null
+++ b/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts
@@ -0,0 +1,44 @@
+import {
+ type CommandErrorResult,
+ type DeviceActionState,
+ type ExecuteDeviceActionReturnType,
+ type OpenAppDAError,
+ type OpenAppDARequiredInteraction,
+ type UserInteractionRequired,
+} from "@ledgerhq/device-management-kit";
+
+import { type Signature } from "@api/model/Signature";
+
+export type SignMessageDAOutput = Signature;
+
+export type SignMessageDAInput = {
+ readonly derivationPath: string;
+ readonly message: string;
+};
+
+export type SignMessageDAError = OpenAppDAError | CommandErrorResult["error"];
+
+type SignMessageDARequiredInteraction =
+ | OpenAppDARequiredInteraction
+ | UserInteractionRequired.SignPersonalMessage;
+
+export type SignMessageDAIntermediateValue = {
+ requiredUserInteraction: SignMessageDARequiredInteraction;
+};
+
+export type SignMessageDAState = DeviceActionState<
+ SignMessageDAOutput,
+ SignMessageDAError,
+ SignMessageDAIntermediateValue
+>;
+
+export type SignMessageDAInternalState = {
+ readonly error: SignMessageDAError | null;
+ readonly signature: Signature | null;
+};
+
+export type SignMessageDAReturnType = ExecuteDeviceActionReturnType<
+ SignMessageDAOutput,
+ SignMessageDAError,
+ SignMessageDAIntermediateValue
+>;
diff --git a/packages/signer/signer-btc/src/api/index.ts b/packages/signer/signer-btc/src/api/index.ts
index a7ef9bf77..36e101c10 100644
--- a/packages/signer/signer-btc/src/api/index.ts
+++ b/packages/signer/signer-btc/src/api/index.ts
@@ -1,2 +1,12 @@
export { type SignerBtc } from "./SignerBtc";
export * from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes";
+export type {
+ SignMessageDAError,
+ SignMessageDAInput,
+ SignMessageDAIntermediateValue,
+ SignMessageDAOutput,
+ SignMessageDAState,
+} from "@api/app-binder/SignMessageDeviceActionType";
+export * from "@api/app-binder/SignMessageDeviceActionType";
+export * from "@api/SignerBtc";
+export * from "@api/SignerBtcBuilder";
diff --git a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts
index d2b79c99f..003bcb245 100644
--- a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts
+++ b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts
@@ -3,7 +3,9 @@ import { type DeviceManagementKit } from "@ledgerhq/device-management-kit";
import { DefaultSignerBtc } from "@internal/DefaultSignerBtc";
import { GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase";
-describe("DefaultSignerSolana", () => {
+import { SignMessageUseCase } from "./use-cases/sign-message/SignMessageUseCase";
+
+describe("DefaultSignerBtc", () => {
it("should be defined", () => {
const signer = new DefaultSignerBtc({
dmk: {} as DeviceManagementKit,
@@ -24,4 +26,17 @@ describe("DefaultSignerSolana", () => {
});
expect(GetExtendedPublicKeyUseCase.prototype.execute).toHaveBeenCalled();
});
+
+ it("should call signMessageUseCase", () => {
+ jest.spyOn(SignMessageUseCase.prototype, "execute");
+ const sessionId = "session-id";
+ const dmk = {
+ executeDeviceAction: jest.fn(),
+ } as unknown as DeviceManagementKit;
+ const derivationPath = "44'/0'/0'/0/0";
+ const message = "message";
+ const signer = new DefaultSignerBtc({ dmk, sessionId });
+ signer.signMessage(derivationPath, message);
+ expect(SignMessageUseCase.prototype.execute).toHaveBeenCalled();
+ });
});
diff --git a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts
index 89b8e785f..203ce128b 100644
--- a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts
+++ b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts
@@ -4,11 +4,13 @@ import {
} from "@ledgerhq/device-management-kit";
import { type Container } from "inversify";
+import { type SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionType";
import { type AddressOptions } from "@api/model/AddressOptions";
import { type SignerBtc } from "@api/SignerBtc";
import { useCasesTypes } from "@internal/use-cases/di/useCasesTypes";
import { type GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase";
+import { type SignMessageUseCase } from "./use-cases/sign-message/SignMessageUseCase";
import { makeContainer } from "./di";
type DefaultSignerBtcConstructorArgs = {
@@ -33,4 +35,13 @@ export class DefaultSignerBtc implements SignerBtc {
)
.execute(derivationPath, { checkOnDevice });
}
+
+ signMessage(
+ _derivationPath: string,
+ _message: string,
+ ): SignMessageDAReturnType {
+ return this._container
+ .get(useCasesTypes.SignMessageUseCase)
+ .execute(_derivationPath, _message);
+ }
}
diff --git a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts
index cf200be32..64a57a77a 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts
@@ -13,6 +13,12 @@ import {
type GetExtendedPublicKeyDAError,
type GetExtendedPublicKeyDAOutput,
} from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes";
+import {
+ type SignMessageDAError,
+ type SignMessageDAIntermediateValue,
+ type SignMessageDAOutput,
+} from "@api/index";
+import { type Signature } from "@api/model/Signature";
import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder";
import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand";
@@ -152,4 +158,65 @@ describe("BtcAppBinder", () => {
});
});
});
+
+ describe("signMessage", () => {
+ it("should return the signature", (done) => {
+ // GIVEN
+ const signature: Signature = {
+ r: `0xDEF1`,
+ s: `0xAFAF`,
+ v: 0,
+ };
+ const message = "Hello, World!";
+
+ jest.spyOn(mockedDmk, "executeDeviceAction").mockReturnValue({
+ observable: from([
+ {
+ status: DeviceActionStatus.Completed,
+ output: signature,
+ } as DeviceActionState<
+ SignMessageDAOutput,
+ SignMessageDAError,
+ SignMessageDAIntermediateValue
+ >,
+ ]),
+ cancel: jest.fn(),
+ });
+
+ // WHEN
+ const appBinder = new BtcAppBinder(mockedDmk, "sessionId");
+ const { observable } = appBinder.signMessage({
+ derivationPath: "44'/60'/3'/2/1",
+ message,
+ });
+
+ // THEN
+ const states: DeviceActionState<
+ SignMessageDAOutput,
+ SignMessageDAError,
+ SignMessageDAIntermediateValue
+ >[] = [];
+ 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);
+ }
+ },
+ });
+ });
+ });
});
diff --git a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts
index 83570fbb4..795a1da93 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts
@@ -10,15 +10,19 @@ import {
GetExtendedPublicKeyDAInput,
GetExtendedPublicKeyReturnType,
} from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes";
+import { SignMessageDAReturnType } from "@api/index";
import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand";
import { externalTypes } from "@internal/externalTypes";
+import { SignMessageDeviceAction } from "./device-action/SignMessage/SignMessageDeviceAction";
+
@injectable()
export class BtcAppBinder {
constructor(
@inject(externalTypes.Dmk) private dmk: DeviceManagementKit,
@inject(externalTypes.SessionId) private sessionId: DeviceSessionId,
) {}
+
getExtendedPublicKey(
args: GetExtendedPublicKeyDAInput,
): GetExtendedPublicKeyReturnType {
@@ -35,4 +39,19 @@ export class BtcAppBinder {
}),
});
}
+
+ signMessage(args: {
+ derivationPath: string;
+ message: string;
+ }): SignMessageDAReturnType {
+ return this.dmk.executeDeviceAction({
+ sessionId: this.sessionId,
+ deviceAction: new SignMessageDeviceAction({
+ input: {
+ derivationPath: args.derivationPath,
+ message: args.message,
+ },
+ }),
+ });
+ }
}
diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts
index 960b10b0a..19ddb619c 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts
@@ -1,11 +1,11 @@
import {
ApduResponse,
CommandResultFactory,
- InvalidStatusWordError,
- isSuccessCommandResult,
+ GlobalCommandErrorHandler,
} from "@ledgerhq/device-management-kit";
import { SW_INTERRUPTED_EXECUTION } from "@internal/app-binder/command/utils/constants";
+import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils";
import { ContinueCommand } from "./ContinueCommand";
@@ -14,34 +14,6 @@ describe("ContinueCommand", (): void => {
payload: new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
};
- const getSignatureResponse = ({
- omitR = false,
- omitS = false,
- }: {
- omitV?: boolean;
- omitR?: boolean;
- omitS?: boolean;
- } = {}) =>
- new Uint8Array([
- ...(omitR ? [] : [0x1b]), // v
- ...(omitR
- ? []
- : [
- 0x97, 0xa4, 0xca, 0x8f, 0x69, 0x46, 0x33, 0x59, 0x26, 0x01, 0xf5,
- 0xa2, 0x3e, 0x0b, 0xcc, 0x55, 0x3c, 0x9d, 0x0a, 0x90, 0xd3, 0xa3,
- 0x42, 0x2d, 0x57, 0x55, 0x08, 0xa9, 0x28, 0x98, 0xb9, 0x6e,
- ]), // r (32 bytes)
- ...(omitS
- ? []
- : [
- 0x69, 0x50, 0xd0, 0x2e, 0x74, 0xe9, 0xc1, 0x02, 0xc1, 0x64, 0xa2,
- 0x25, 0x53, 0x30, 0x82, 0xca, 0xbd, 0xd8, 0x90, 0xef, 0xc4, 0x63,
- 0xf6, 0x7f, 0x60, 0xce, 0xfe, 0x8c, 0x3f, 0x87, 0xcf, 0xce,
- ]), // s (32 bytes)
- ]);
-
- const USER_DENIED_STATUS = new Uint8Array([0x69, 0x85]);
-
const EXPECTED_APDU = new Uint8Array([
0xf8, // CLA
0x01, // INS
@@ -54,10 +26,21 @@ describe("ContinueCommand", (): void => {
0xef, // Payload data
]);
+ const parser = (response: ApduResponse) => {
+ if (BtcCommandUtils.isContinueResponse(response)) {
+ return CommandResultFactory({
+ data: response,
+ });
+ }
+ return CommandResultFactory({
+ error: GlobalCommandErrorHandler.handle(response),
+ });
+ };
+
describe("getApdu", () => {
it("should return correct APDU for given payload", () => {
// given
- const command = new ContinueCommand(defaultArgs);
+ const command = new ContinueCommand(defaultArgs, parser);
// when
const apdu = command.getApdu();
// then
@@ -68,7 +51,7 @@ describe("ContinueCommand", (): void => {
describe("parseResponse", () => {
it("should return the APDU response if it's a continue response", () => {
// given
- const command = new ContinueCommand(defaultArgs);
+ const command = new ContinueCommand(defaultArgs, parser);
const continueResponseData = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const apduResponse = new ApduResponse({
statusCode: SW_INTERRUPTED_EXECUTION,
@@ -85,146 +68,5 @@ describe("ContinueCommand", (): void => {
}),
);
});
-
- it("should return correct signature after successful signing", () => {
- // given
- const command = new ContinueCommand(defaultArgs);
- const signatureData = getSignatureResponse();
- const apduResponse = new ApduResponse({
- statusCode: new Uint8Array([0x90, 0x00]),
- data: signatureData,
- });
-
- // when
- const response = command.parseResponse(apduResponse);
-
- // then
- expect(response).toStrictEqual(
- CommandResultFactory({
- data: {
- v: 27,
- r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e",
- s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce",
- },
- }),
- );
- });
-
- it("should return an error if user denied the operation", () => {
- // given
- const command = new ContinueCommand(defaultArgs);
- const apduResponse = new ApduResponse({
- statusCode: USER_DENIED_STATUS,
- data: new Uint8Array([]),
- });
- const response = command.parseResponse(apduResponse);
-
- // then
- expect(isSuccessCommandResult(response)).toBe(false);
- if (!isSuccessCommandResult(response)) {
- expect(response.error).toBeDefined();
- }
- });
-
- it("should return an error when the response data is empty but status is success", () => {
- // given
- const command = new ContinueCommand(defaultArgs);
- const apduResponse = new ApduResponse({
- statusCode: new Uint8Array([0x90, 0x00]),
- data: new Uint8Array([]),
- });
-
- // when
- const response = command.parseResponse(apduResponse);
-
- // then
- expect(isSuccessCommandResult(response)).toBe(false);
- expect(response).toStrictEqual(
- CommandResultFactory({
- error: new InvalidStatusWordError("V is missing"),
- }),
- );
- });
-
- it("should return correct data when the response data is a valid signature", () => {
- // given
- const command = new ContinueCommand(defaultArgs);
- const signatureData = getSignatureResponse();
- const apduResponse = new ApduResponse({
- statusCode: new Uint8Array([0x90, 0x00]),
- data: signatureData,
- });
-
- // when
- const response = command.parseResponse(apduResponse);
-
- // then
- expect(isSuccessCommandResult(response)).toBe(true);
- if (isSuccessCommandResult(response)) {
- expect(response.data).toStrictEqual({
- v: 27,
- r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e",
- s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce",
- });
- }
- });
-
- it("should return an error if 'r' is missing in the signature response", () => {
- // given
- const command = new ContinueCommand(defaultArgs);
- const signatureData = getSignatureResponse({ omitR: true });
- const apduResponse = new ApduResponse({
- statusCode: new Uint8Array([0x90, 0x00]),
- data: signatureData,
- });
-
- // when
- const response = command.parseResponse(apduResponse);
-
- // then
- expect(response).toStrictEqual(
- CommandResultFactory({
- error: new InvalidStatusWordError("R is missing"),
- }),
- );
- });
-
- it("should return an error if 's' is missing in the signature response", () => {
- // given
- const command = new ContinueCommand(defaultArgs);
- const signatureData = getSignatureResponse({ omitS: true });
- const apduResponse = new ApduResponse({
- statusCode: new Uint8Array([0x90, 0x00]),
- data: signatureData,
- });
-
- // when
- const response = command.parseResponse(apduResponse);
-
- // then
- expect(response).toStrictEqual(
- CommandResultFactory({
- error: new InvalidStatusWordError("S is missing"),
- }),
- );
- });
-
- it("should return a global error for unknown status codes", () => {
- // given
- const command = new ContinueCommand(defaultArgs);
- const apduResponse = new ApduResponse({
- statusCode: new Uint8Array([0x6a, 0x80]),
- data: new Uint8Array([]),
- });
-
- // when
- const response = command.parseResponse(apduResponse);
-
- // then
- expect(isSuccessCommandResult(response)).toBe(false);
- if (!isSuccessCommandResult(response)) {
- expect(response.error).toBeDefined();
- }
- });
});
});
diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts
index 0d919f864..5379a9cf4 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts
@@ -1,36 +1,25 @@
import {
type Apdu,
ApduBuilder,
- ApduParser,
type ApduResponse,
type Command,
type CommandResult,
- CommandResultFactory,
- CommandUtils,
- GlobalCommandErrorHandler,
- InvalidStatusWordError,
- isCommandErrorCode,
} from "@ledgerhq/device-management-kit";
-import { type Signature } from "@api/model/Signature";
-import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils";
-
-import {
- BitcoinAppCommandError,
- bitcoinAppErrors,
-} from "./utils/bitcoinAppErrors";
-
export type ContinueCommandArgs = {
payload: Uint8Array;
};
-const R_LENGTH = 32;
-const S_LENGTH = 32;
-
-export class ContinueCommand
- implements Command
+export class ContinueCommand
+ implements Command
{
- constructor(private readonly args: ContinueCommandArgs) {}
+ constructor(
+ private readonly args: ContinueCommandArgs,
+ private readonly parseFn: (
+ response: ApduResponse,
+ ) => CommandResult,
+ ) {}
+
getApdu(): Apdu {
return new ApduBuilder({
cla: 0xf8,
@@ -42,71 +31,7 @@ export class ContinueCommand
.build();
}
- parseResponse(
- response: ApduResponse,
- ): CommandResult {
- if (BtcCommandUtils.isContinueResponse(response)) {
- return CommandResultFactory({
- data: response,
- });
- }
- if (!CommandUtils.isSuccessResponse(response)) {
- return CommandResultFactory({
- error: GlobalCommandErrorHandler.handle(response),
- });
- }
-
- // !!! POC to test with the real device
- // !!! final implementation will have this part injected
- const parser = new ApduParser(response);
- const errorCode = parser.encodeToHexaString(response.statusCode);
- if (isCommandErrorCode(errorCode, bitcoinAppErrors)) {
- return CommandResultFactory({
- error: new BitcoinAppCommandError({
- ...bitcoinAppErrors[errorCode],
- errorCode,
- }),
- });
- }
-
- // Extract 'v'
- const v = parser.extract8BitUInt();
- if (v === undefined) {
- return CommandResultFactory({
- error: new InvalidStatusWordError("V is missing"),
- });
- }
-
- // Extract 'r'
- const r = parser.encodeToHexaString(
- parser.extractFieldByLength(R_LENGTH),
- true,
- );
- if (!r) {
- return CommandResultFactory({
- error: new InvalidStatusWordError("R is missing"),
- });
- }
-
- // Extract 's'
- const s = parser.encodeToHexaString(
- parser.extractFieldByLength(S_LENGTH),
- true,
- );
- if (!s) {
- return CommandResultFactory({
- error: new InvalidStatusWordError("S is missing"),
- });
- }
-
- return CommandResultFactory({
- data: {
- v,
- r,
- s,
- },
- });
- // !!! POC to test with the real device
- // !!! final implementation will have this part injected
+ parseResponse(response: ApduResponse): CommandResult {
+ return this.parseFn(response);
}
}
diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.test.ts
index bb8cddebd..536a8bf30 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.test.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.test.ts
@@ -1,8 +1,8 @@
import { Just, Nothing } from "purify-ts";
import {
- BUFFER_SIZE,
ClientCommandCodes,
+ SHA256_SIZE,
} from "@internal/app-binder/command/utils/constants";
import { type DataStore } from "@internal/data-store/model/DataStore";
import { encodeVarint } from "@internal/utils/Varint";
@@ -39,8 +39,8 @@ describe("GetMerkleLeafIndexCommandHandler", () => {
it("should return the index when the Merkle leaf is found", () => {
// given
- const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0xaa);
- const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xff);
+ const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0xaa);
+ const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xff);
const handlerRequest = createRequest(
COMMAND_CODE,
merkleRootHash,
@@ -75,8 +75,8 @@ describe("GetMerkleLeafIndexCommandHandler", () => {
it("should return a failure response when the Merkle leaf is not found", () => {
// given
- const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0xaa);
- const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xff);
+ const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0xaa);
+ const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xff);
const handlerRequest = createRequest(
COMMAND_CODE,
merkleRootHash,
@@ -107,8 +107,8 @@ describe("GetMerkleLeafIndexCommandHandler", () => {
it("should correctly handle a leaf index of zero", () => {
// given
- const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0xaa);
- const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xff);
+ const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0xaa);
+ const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xff);
const handlerRequest = createRequest(
COMMAND_CODE,
merkleRootHash,
@@ -141,16 +141,16 @@ describe("GetMerkleLeafIndexCommandHandler", () => {
expect(commandHandlerContext.queue).toHaveLength(0);
});
- it("should correctly handle the maximum BUFFER_SIZE-bit unsigned integer as a leaf index", () => {
+ it("should correctly handle the maximum SHA256_SIZE-bit unsigned integer as a leaf index", () => {
// given
- const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0xaa);
- const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xff);
+ const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0xaa);
+ const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xff);
const handlerRequest = createRequest(
COMMAND_CODE,
merkleRootHash,
leafNodeHash,
);
- const maximumLeafIndex = 0xffffffff; // maximum value of a BUFFER_SIZE-bit unsigned integer
+ const maximumLeafIndex = 0xffffffff; // maximum value of a SHA256_SIZE-bit unsigned integer
const encodedLeafIndexVarint =
encodeVarint(maximumLeafIndex).unsafeCoerce();
diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.ts
index f979cbaaf..937f9318e 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafIndexCommandHandler.ts
@@ -1,7 +1,7 @@
import { type DmkError } from "@ledgerhq/device-management-kit";
import { type Either, Right } from "purify-ts";
-import { BUFFER_SIZE } from "@internal/app-binder/command/utils/constants";
+import { SHA256_SIZE } from "@internal/app-binder/command/utils/constants";
import { encodeVarint } from "@internal/utils/Varint";
import {
@@ -13,8 +13,8 @@ export const GetMerkleLeafIndexCommandHandler: CommandHandler = (
request: Uint8Array,
commandHandlerContext: CommandHandlerContext,
): Either => {
- const merkleRootHash = request.slice(1, BUFFER_SIZE + 1); // extract the Merkle root hash
- const leafNodeHash = request.slice(BUFFER_SIZE + 1, BUFFER_SIZE * 2 + 1); // extract the leaf hash
+ const merkleRootHash = request.slice(1, SHA256_SIZE + 1); // extract the Merkle root hash
+ const leafNodeHash = request.slice(SHA256_SIZE + 1, SHA256_SIZE * 2 + 1); // extract the leaf hash
const maybeMerkleIndex = commandHandlerContext.dataStore.getMerkleLeafIndex(
merkleRootHash,
diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.test.ts
index db3215dd5..cbc2c1b5e 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.test.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.test.ts
@@ -2,8 +2,8 @@ import { APDU_MAX_PAYLOAD } from "@ledgerhq/device-management-kit";
import { Just, Nothing } from "purify-ts";
import {
- BUFFER_SIZE,
ClientCommandCodes,
+ SHA256_SIZE,
} from "@internal/app-binder/command/utils/constants";
import { type DataStore } from "@internal/data-store/model/DataStore";
import { encodeVarint } from "@internal/utils/Varint";
@@ -47,7 +47,7 @@ describe("GetMerkleLeafProofCommandHandler", () => {
it("should return the Merkle leaf and proof when found and proof length is less than or equal to the maximum allowed", () => {
// given
- const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0x01);
+ const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0x01);
const totalElements = 1;
const proofElementIndex = 2;
const request = createRequest(
@@ -56,10 +56,10 @@ describe("GetMerkleLeafProofCommandHandler", () => {
totalElements,
proofElementIndex,
);
- const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xaa);
+ const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xaa);
const proofElements = [
- new Uint8Array(BUFFER_SIZE).fill(0xbb),
- new Uint8Array(BUFFER_SIZE).fill(0xcc),
+ new Uint8Array(SHA256_SIZE).fill(0xbb),
+ new Uint8Array(SHA256_SIZE).fill(0xcc),
];
// when
@@ -72,19 +72,19 @@ describe("GetMerkleLeafProofCommandHandler", () => {
commandHandlerContext,
);
- const maximumPayloadSize = APDU_MAX_PAYLOAD - BUFFER_SIZE - 1 - 1;
- const maximumProofElements = Math.floor(maximumPayloadSize / BUFFER_SIZE);
+ const maximumPayloadSize = APDU_MAX_PAYLOAD - SHA256_SIZE - 1 - 1;
+ const maximumProofElements = Math.floor(maximumPayloadSize / SHA256_SIZE);
const proofElementsToInclude = Math.min(
proofElements.length,
maximumProofElements,
);
const expectedResponse = new Uint8Array(
- BUFFER_SIZE + 1 + 1 + BUFFER_SIZE * proofElementsToInclude,
+ SHA256_SIZE + 1 + 1 + SHA256_SIZE * proofElementsToInclude,
);
let responseBufferOffset = 0;
expectedResponse.set(leafNodeHash, responseBufferOffset); // leafHash
- responseBufferOffset += BUFFER_SIZE;
+ responseBufferOffset += SHA256_SIZE;
expectedResponse[responseBufferOffset++] = proofElements.length; // total proof length
expectedResponse[responseBufferOffset++] = proofElementsToInclude;
for (
@@ -96,7 +96,7 @@ describe("GetMerkleLeafProofCommandHandler", () => {
proofElements[proofElementIndex] as Uint8Array,
responseBufferOffset,
);
- responseBufferOffset += BUFFER_SIZE;
+ responseBufferOffset += SHA256_SIZE;
}
// then
@@ -111,7 +111,7 @@ describe("GetMerkleLeafProofCommandHandler", () => {
it("should handle proof longer than the maximum allowed by queuing the remaining proof elements", () => {
// given
- const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0x02);
+ const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0x02);
const totalElements = 1;
const proofElementIndex = 3;
const request = createRequest(
@@ -120,11 +120,11 @@ describe("GetMerkleLeafProofCommandHandler", () => {
totalElements,
proofElementIndex,
);
- const leafNodeHash = new Uint8Array(BUFFER_SIZE).fill(0xdd);
+ const leafNodeHash = new Uint8Array(SHA256_SIZE).fill(0xdd);
const totalProofLength = 10;
const proofElements = Array.from(
{ length: totalProofLength },
- (_, proofIndex) => new Uint8Array(BUFFER_SIZE).fill(0xee + proofIndex),
+ (_, proofIndex) => new Uint8Array(SHA256_SIZE).fill(0xee + proofIndex),
);
// when
@@ -137,19 +137,19 @@ describe("GetMerkleLeafProofCommandHandler", () => {
commandHandlerContext,
);
- const maximumPayloadSize = APDU_MAX_PAYLOAD - BUFFER_SIZE - 1 - 1;
- const maximumProofElements = Math.floor(maximumPayloadSize / BUFFER_SIZE);
+ const maximumPayloadSize = APDU_MAX_PAYLOAD - SHA256_SIZE - 1 - 1;
+ const maximumProofElements = Math.floor(maximumPayloadSize / SHA256_SIZE);
const proofElementsToInclude = Math.min(
proofElements.length,
maximumProofElements,
);
const expectedResponse = new Uint8Array(
- BUFFER_SIZE + 1 + 1 + BUFFER_SIZE * proofElementsToInclude,
+ SHA256_SIZE + 1 + 1 + SHA256_SIZE * proofElementsToInclude,
);
let responseBufferOffset = 0;
expectedResponse.set(leafNodeHash, responseBufferOffset); // leafHash
- responseBufferOffset += BUFFER_SIZE;
+ responseBufferOffset += SHA256_SIZE;
expectedResponse[responseBufferOffset++] = proofElements.length; // total proof length
expectedResponse[responseBufferOffset++] = proofElementsToInclude;
for (
@@ -161,7 +161,7 @@ describe("GetMerkleLeafProofCommandHandler", () => {
proofElements[proofElementIndex] as Uint8Array,
responseBufferOffset,
);
- responseBufferOffset += BUFFER_SIZE;
+ responseBufferOffset += SHA256_SIZE;
}
// then
@@ -187,7 +187,7 @@ describe("GetMerkleLeafProofCommandHandler", () => {
it("should return an error when the Merkle proof is not found in the data store", () => {
// given
- const merkleRootHash = new Uint8Array(BUFFER_SIZE).fill(0x04);
+ const merkleRootHash = new Uint8Array(SHA256_SIZE).fill(0x04);
const totalElements = 1;
const proofElementIndex = 5;
const request = createRequest(
diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.ts
index 10adcf31f..31c54a7c4 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetMerkleLeafProofCommandHandler.ts
@@ -1,12 +1,13 @@
import {
APDU_MAX_PAYLOAD,
+ ByteArrayBuilder,
ByteArrayParser,
type DmkError,
} from "@ledgerhq/device-management-kit";
-import { type Either, Left, type Maybe, Right } from "purify-ts";
+import { type Either, Left, Right } from "purify-ts";
-import { BUFFER_SIZE } from "@internal/app-binder/command/utils/constants";
-import { extractVarint, type Varint } from "@internal/utils/Varint";
+import { SHA256_SIZE } from "@internal/app-binder/command/utils/constants";
+import { extractVarint } from "@internal/utils/Varint";
import {
type CommandHandler,
@@ -18,72 +19,67 @@ export const GetMerkleLeafProofCommandHandler: CommandHandler = (
request: Uint8Array,
context: CommandHandlerContext,
): Either => {
- const rootHash = request.slice(1, BUFFER_SIZE + 1);
- const parser = new ByteArrayParser(request.slice(33));
+ const rootHash = request.slice(1, SHA256_SIZE + 1);
+ const parser = new ByteArrayParser(request.slice(SHA256_SIZE + 1));
- const numberOfElementsMaybe: Maybe = extractVarint(parser);
- if (numberOfElementsMaybe.isNothing()) {
- return Left(new ClientCommandHandlerError("Failed to extract 'n' varint"));
- }
- const numberOfElementsVarint = numberOfElementsMaybe.extract();
- if (!numberOfElementsVarint) {
- return Left(new ClientCommandHandlerError("Failed to extract 'n' varint"));
- }
+ return extractVarint(parser).mapOrDefault(
+ () =>
+ extractVarint(parser).mapOrDefault(
+ (proofIndexVarint) =>
+ context.dataStore
+ .getMerkleProof(rootHash, proofIndexVarint.value)
+ .mapOrDefault(
+ ({ proof, leafHash }) => {
+ const maxPayloadSize = APDU_MAX_PAYLOAD - SHA256_SIZE - 1 - 1;
+ const maxProofElements = Math.floor(
+ maxPayloadSize / SHA256_SIZE,
+ );
+ const proofElementsCount = Math.min(
+ proof.length,
+ maxProofElements,
+ );
- const proofIndexMaybe: Maybe = extractVarint(parser);
- if (proofIndexMaybe.isNothing()) {
- return Left(new ClientCommandHandlerError("Failed to extract 'i' varint"));
- }
- const proofIndexVarint = proofIndexMaybe.extract();
- if (!proofIndexVarint) {
- return Left(new ClientCommandHandlerError("Failed to extract 'n' varint"));
- }
- const proofIndex = proofIndexVarint.value;
+ const builder = new ByteArrayBuilder()
+ .addBufferToData(leafHash)
+ .add8BitUIntToData(proof.length)
+ .add8BitUIntToData(proofElementsCount);
- const maybeProof = context.dataStore.getMerkleProof(rootHash, proofIndex);
+ for (let i = 0; i < proofElementsCount; i++) {
+ const proofElement = proof[i];
+ if (proofElement) {
+ builder.addBufferToData(proofElement);
+ }
+ }
- return maybeProof.mapOrDefault(
- ({ proof, leafHash }) => {
- const maxPayloadSize = APDU_MAX_PAYLOAD - BUFFER_SIZE - 1 - 1; // 255 total - 32 (leafHash) - 1 (proof.length) - 1 (p)
+ const response = builder.build();
- const maxProofElements = Math.floor(maxPayloadSize / BUFFER_SIZE);
- const proofElementsCount = Math.min(proof.length, maxProofElements);
+ if (proofElementsCount < proof.length) {
+ for (let i = proofElementsCount; i < proof.length; i++) {
+ const proofElement = proof[i];
+ if (proofElement instanceof Uint8Array) {
+ context.queue.push(proofElement);
+ }
+ }
+ }
- const responseSize =
- BUFFER_SIZE + 1 + 1 + BUFFER_SIZE * proofElementsCount;
- const response = new Uint8Array(responseSize);
- let responseOffset = 0;
-
- response.set(leafHash, responseOffset);
- responseOffset += BUFFER_SIZE;
-
- response[responseOffset++] = proof.length;
- response[responseOffset++] = proofElementsCount;
-
- for (
- let proofElementIndex = 0;
- proofElementIndex < proofElementsCount;
- proofElementIndex++
- ) {
- const proofElement = proof[proofElementIndex];
- if (proofElement) {
- response.set(proofElement, responseOffset);
- }
- responseOffset += BUFFER_SIZE;
- }
-
- if (proofElementsCount < proof.length) {
- for (
- let remainingProofElementIndex = proofElementsCount;
- remainingProofElementIndex < proof.length;
- remainingProofElementIndex++
- ) {
- context.queue.push(proof[remainingProofElementIndex] as Uint8Array);
- }
- }
-
- return Right(response);
- },
- Left(new ClientCommandHandlerError("Merkle proof not found in dataStore")),
+ return Right(response);
+ },
+ Left(
+ new ClientCommandHandlerError(
+ "Merkle proof not found in dataStore",
+ ),
+ ),
+ ),
+ Left(
+ new ClientCommandHandlerError(
+ "Failed to extract the proofIndex varint",
+ ),
+ ),
+ ),
+ Left(
+ new ClientCommandHandlerError(
+ "Failed to extract the numberOfElements varint",
+ ),
+ ),
);
};
diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.test.ts
index de5200fea..53843d7ca 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.test.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.test.ts
@@ -1,9 +1,9 @@
import { type DmkError } from "@ledgerhq/device-management-kit";
-import { Just, Nothing } from "purify-ts";
+import { Either, Just, Nothing } from "purify-ts";
import {
- BUFFER_SIZE,
ClientCommandCodes,
+ SHA256_SIZE,
} from "@internal/app-binder/command/utils/constants";
import { type DataStore } from "@internal/data-store/model/DataStore";
import { encodeVarint } from "@internal/utils/Varint";
@@ -38,7 +38,7 @@ describe("GetPreimageCommandHandler", () => {
it("should return the preimage when it is found and its length is within the maximum payload size", () => {
// given
- const requestHash = new Uint8Array(BUFFER_SIZE).fill(0x01);
+ const requestHash = new Uint8Array(SHA256_SIZE).fill(0x01);
const preimage = new Uint8Array([0xaa, 0xbb, 0xcc]);
const preimageLength = preimage.length;
@@ -74,7 +74,7 @@ describe("GetPreimageCommandHandler", () => {
it("should handle a preimage longer than the maximum payload size by queuing the remaining bytes", () => {
// given
- const requestHash = new Uint8Array(BUFFER_SIZE).fill(0x02);
+ const requestHash = new Uint8Array(SHA256_SIZE).fill(0x02);
const preimage = new Uint8Array(300).fill(0xff);
const preimageLength = preimage.length;
@@ -129,7 +129,7 @@ describe("GetPreimageCommandHandler", () => {
it("should return an error when the preimage is not found", () => {
// given
- const requestHash = new Uint8Array(BUFFER_SIZE).fill(0x03);
+ const requestHash = new Uint8Array(SHA256_SIZE).fill(0x03);
// when
mockDataStore.getPreimage.mockReturnValue(Nothing);
@@ -157,7 +157,7 @@ describe("GetPreimageCommandHandler", () => {
it("should handle a preimage length where the maximum payload size exactly matches the preimage length", () => {
// given
- const requestHash = new Uint8Array(BUFFER_SIZE).fill(0x05);
+ const requestHash = new Uint8Array(SHA256_SIZE).fill(0x05);
const preimage = new Uint8Array(252);
preimage.fill(0x77);
@@ -188,14 +188,7 @@ describe("GetPreimageCommandHandler", () => {
expectedResponse.set(preimage, varintEncodedLength.length + 1);
// then
- handlerResult.caseOf({
- Left: (_) => {
- throw new Error("Expected Right, got Left");
- },
- Right: (response) => {
- expect(response).toEqual(expectedResponse);
- expect(commandHandlerContext.queue).toHaveLength(0);
- },
- });
+ expect(handlerResult).toStrictEqual(Either.of(expectedResponse));
+ expect(commandHandlerContext.queue).toHaveLength(0);
});
});
diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.ts b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.ts
index 4db5841e9..2e53f8cec 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/command/client-command-handlers/GetPreimageCommandHandler.ts
@@ -1,5 +1,6 @@
import {
APDU_MAX_PAYLOAD,
+ ByteArrayBuilder,
type DmkError,
} from "@ledgerhq/device-management-kit";
import { type Either, Left, Right } from "purify-ts";
@@ -30,14 +31,11 @@ export const GetPreimageCommandHandler: CommandHandler = (
preimage.length,
);
- const response = new Uint8Array(
- preimageLengthVarint.length + 1 + bytesToIncludeInResponse,
- );
- let responseOffset = 0;
- response.set(preimageLengthVarint, responseOffset); // preimage length varint
- responseOffset += preimageLengthVarint.length;
- response[responseOffset++] = bytesToIncludeInResponse; // number of bytes in the response
- response.set(preimage.slice(0, bytesToIncludeInResponse), responseOffset);
+ const response = new ByteArrayBuilder()
+ .addBufferToData(preimageLengthVarint) // varint
+ .add8BitUIntToData(bytesToIncludeInResponse) // byte count
+ .addBufferToData(preimage.slice(0, bytesToIncludeInResponse)) // requested part of the preimage
+ .build();
if (bytesToIncludeInResponse < preimage.length) {
for (
diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/utils/constants.ts b/packages/signer/signer-btc/src/internal/app-binder/command/utils/constants.ts
index 94793981e..5118812a4 100644
--- a/packages/signer/signer-btc/src/internal/app-binder/command/utils/constants.ts
+++ b/packages/signer/signer-btc/src/internal/app-binder/command/utils/constants.ts
@@ -1,6 +1,6 @@
export const PROTOCOL_VERSION = 1;
-export const BUFFER_SIZE = 32;
+export const SHA256_SIZE = 32;
export const CHUNK_SIZE = 64;
diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts
new file mode 100644
index 000000000..d2b50da47
--- /dev/null
+++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts
@@ -0,0 +1,373 @@
+import {
+ CommandResultFactory,
+ DeviceActionStatus,
+ UnknownDeviceExchangeError,
+ UserInteractionRequired,
+} from "@ledgerhq/device-management-kit";
+import { UnknownDAError } from "@ledgerhq/device-management-kit";
+import { InvalidStatusWordError } from "@ledgerhq/device-management-kit";
+
+import { type SignMessageDAState } from "@api/index";
+import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi";
+import { setupOpenAppDAMock } from "@internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock";
+import { testDeviceActionStates } from "@internal/app-binder/device-action/__test-utils__/testDeviceActionStates";
+
+import { SignMessageDeviceAction } from "./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 signPersonalMessageMock = jest.fn();
+
+ function extractDependenciesMock() {
+ return {
+ signMessage: signPersonalMessageMock,
+ };
+ }
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe("Success case", () => {
+ it("should call external dependencies with the correct parameters", (done) => {
+ setupOpenAppDAMock();
+
+ const deviceAction = new SignMessageDeviceAction({
+ input: {
+ derivationPath: "44'/60'/0'/0/0",
+ message: "Hello world",
+ },
+ });
+
+ // Mock the dependencies to return some sample data
+ jest
+ .spyOn(deviceAction, "extractDependencies")
+ .mockReturnValue(extractDependenciesMock());
+ signPersonalMessageMock.mockResolvedValueOnce(
+ CommandResultFactory({
+ data: {
+ v: 0x1c,
+ r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788",
+ s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513",
+ },
+ }),
+ );
+
+ // Expected intermediate values for the following state sequence:
+ // Initial -> OpenApp -> BuildContext -> ProvideContext -> SignTypedData
+ 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: {
+ v: 0x1c,
+ r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788",
+ s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513",
+ },
+ status: DeviceActionStatus.Completed,
+ },
+ ];
+
+ const { observable } = testDeviceActionStates(
+ deviceAction,
+ expectedStates,
+ makeDeviceActionInternalApiMock(),
+ done,
+ );
+
+ // Verify mocks calls parameters
+ observable.subscribe({
+ complete: () => {
+ expect(signPersonalMessageMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ input: {
+ derivationPath: "44'/60'/0'/0/0",
+ message: "Hello world",
+ },
+ }),
+ );
+ },
+ });
+ });
+ });
+
+ 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",
+ },
+ });
+
+ testDeviceActionStates(
+ deviceAction,
+ expectedStates,
+ makeDeviceActionInternalApiMock(),
+ done,
+ );
+ });
+
+ it("Error if the signPersonalMessage fails", (done) => {
+ setupOpenAppDAMock();
+
+ const deviceAction = new SignMessageDeviceAction({
+ input: {
+ derivationPath: "44'/60'/0'/0/0",
+ message: "Hello world",
+ },
+ });
+
+ // Mock the dependencies to return some sample data
+ jest
+ .spyOn(deviceAction, "extractDependencies")
+ .mockReturnValue(extractDependenciesMock());
+ signPersonalMessageMock.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"),
+ },
+ ];
+
+ testDeviceActionStates(
+ deviceAction,
+ expectedStates,
+ makeDeviceActionInternalApiMock(),
+ done,
+ );
+ });
+
+ it("Error if the signPersonalMessage throws an exception", (done) => {
+ setupOpenAppDAMock();
+
+ const deviceAction = new SignMessageDeviceAction({
+ input: {
+ derivationPath: "44'/60'/0'/0/0",
+ message: "Hello world",
+ },
+ });
+
+ // Mock the dependencies to return some sample data
+ jest
+ .spyOn(deviceAction, "extractDependencies")
+ .mockReturnValue(extractDependenciesMock());
+ signPersonalMessageMock.mockRejectedValueOnce(
+ new InvalidStatusWordError("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 InvalidStatusWordError("Mocked error"),
+ },
+ ];
+
+ testDeviceActionStates(
+ deviceAction,
+ expectedStates,
+ makeDeviceActionInternalApiMock(),
+ done,
+ );
+ });
+
+ it("Error if signPersonalMessage return an error", (done) => {
+ setupOpenAppDAMock();
+
+ const deviceAction = new SignMessageDeviceAction({
+ input: {
+ derivationPath: "44'/60'/0'/0/0",
+ message: "Hello world",
+ },
+ });
+
+ // Mock the dependencies to return some sample data
+ jest
+ .spyOn(deviceAction, "extractDependencies")
+ .mockReturnValue(extractDependenciesMock());
+ signPersonalMessageMock.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"),
+ },
+ ];
+
+ testDeviceActionStates(
+ deviceAction,
+ expectedStates,
+ makeDeviceActionInternalApiMock(),
+ done,
+ );
+ });
+
+ 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",
+ },
+ });
+
+ // Mock the dependencies to return some sample data
+ jest
+ .spyOn(deviceAction, "extractDependencies")
+ .mockReturnValue(extractDependenciesMock());
+ signPersonalMessageMock.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"),
+ },
+ ];
+
+ testDeviceActionStates(
+ deviceAction,
+ expectedStates,
+ makeDeviceActionInternalApiMock(),
+ done,
+ );
+ });
+ });
+});
diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts
new file mode 100644
index 000000000..9a5060101
--- /dev/null
+++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts
@@ -0,0 +1,224 @@
+import {
+ type CommandResult,
+ 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/SignMessageDeviceActionType";
+import { type Signature } from "@api/model/Signature";
+import {
+ SendSignMessageTask,
+ type SendSignMessageTaskArgs,
+} from "@internal/app-binder/task/SignMessageTask";
+
+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: "Bitcoin" },
+ }).makeStateMachine(internalApi),
+ signMessage: fromPromise(signMessage),
+ },
+ 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 N4IgpgJg5mDOIC5QGUCWUB2AFMAnWA9hgIYA2AsnLMTACJgBuqAxmAILMAuqRAdAPIAHMBjaDB9Jqw7ciAYghEwvVBgYEA1soLDR45J2Kcw5YswAWqsAG0ADAF1EoQQVipZGJyAAeiACwAzADsvACcABwAjKGRtuG2AGyRSX4ANCAAnoiRAEwArLy2uX6RQUGRpeHhfgC+NelomDj4RGSUsNR0jCzsXDwYvADC5mDMGkIiYhLd0n1EAEpwAK6knHJ2jkggLm4eXr4IoRG8eTlFOaWhQX45QaHpWQiRN4UBfu8JeaE3obY5OXUGuhsHhCCQKFQaGBJD0ZP0hiMxhM9NMpL0PItYCs1tZIptnK53P19ogjuETmdcpdrrd7pl-M8wnkgvkgvFbH48n4EkFASBGiCWuD2p1oTN0fCBc0wW1ITAFEoVGpNMo3E1Qa0IR0oRsvDsiUQSU88tVeOEktEEuE8m8cjcHqScicgokTXk8rZygFQgD6vzgdLNSKoTDZh5eFKNcK5WA5HhcARcLxBKQjAAzRMAW14asFMq1ot1W31ey2B0iJr8ZotoStNpu9vpCDtoTNHr8PoizyKvL9kaFsu1XTRcL4-fzwZgmOxw1GGnWDj1hNLoAOFwCBWC5u5RySEQdT1sra+OSt1wCAQuzICfPHQZjoYlY4DUcHounq1nY3WeKXu2JZaIOum5sgkO61tE4QHhWBS2HkCT-G8R5wc8N58hgBAQHAXh3tGQ5iiOcyeMWy4AauiAALQJAeVGFLY9EMYxDG9kC6oDgWIbiqOAzIlMj7cX+BrEeRCCNo8sS2CcRz-B6zIsnBaGsXm974fxREInOvHiGpGLLKsgkrj4iBnrwFwVgkCHfEeCQBNB3K8IEpxBO6ySep8in+mxE4Plx6m4W+UIGWRRlPJEVRmncOTmhyLI2QeUS8GFQRvPBXZRLWt4vuxk4EbCflZd5+EfpwX4aEFhqAU84RRYUpyXmBATJBeQTQZEARhKEG5Ne69GUplXkqaKOmSkszCsB05XCSFOSXgUyVshUdoJLYF7QYkvAXkUER3H45q1qE-XKXhQ2+eGACiuAJrgk1GjN+S8PNUTFMtq1Nrckl-O6wTct6KF5HUdRAA */
+ 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: "Bitcoin" },
+ 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: "SignMessage",
+ guard: "noInternalError",
+ },
+ "Error",
+ ],
+ },
+ SignMessage: {
+ entry: assign({
+ intermediateValue: {
+ requiredUserInteraction:
+ UserInteractionRequired.SignPersonalMessage,
+ },
+ }),
+ exit: assign({
+ intermediateValue: {
+ requiredUserInteraction: UserInteractionRequired.None,
+ },
+ }),
+ invoke: {
+ id: "signMessage",
+ src: "signMessage",
+ input: ({ context }) => ({
+ derivationPath: context.input.derivationPath,
+ message: 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: {
+ derivationPath: string;
+ message: string;
+ };
+ }) => new SendSignMessageTask(internalApi, arg0.input).run();
+
+ return {
+ signMessage,
+ };
+ }
+}
diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/makeInternalApi.ts
new file mode 100644
index 000000000..08104df33
--- /dev/null
+++ b/packages/signer/signer-btc/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-btc/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock.ts
new file mode 100644
index 000000000..3d27aabf5
--- /dev/null
+++ b/packages/signer/signer-btc/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-btc/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/testDeviceActionStates.ts
new file mode 100644
index 000000000..bc8f1ce01
--- /dev/null
+++ b/packages/signer/signer-btc/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