From a3424076d06855b19f69c0c0025beba21a36a94e Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 27 Dec 2024 18:21:21 +0100 Subject: [PATCH 01/11] :art: (dmk): Export CommandSuccessResult --- .changeset/odd-spies-chew.md | 5 +++++ .../src/api/command/model/CommandResult.ts | 2 +- packages/device-management-kit/src/api/types.ts | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/odd-spies-chew.md diff --git a/.changeset/odd-spies-chew.md b/.changeset/odd-spies-chew.md new file mode 100644 index 000000000..b3bacf612 --- /dev/null +++ b/.changeset/odd-spies-chew.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit": patch +--- + +Expose CommandSuccessResult diff --git a/packages/device-management-kit/src/api/command/model/CommandResult.ts b/packages/device-management-kit/src/api/command/model/CommandResult.ts index e584f99ce..27294d88f 100644 --- a/packages/device-management-kit/src/api/command/model/CommandResult.ts +++ b/packages/device-management-kit/src/api/command/model/CommandResult.ts @@ -14,7 +14,7 @@ export enum CommandResultStatus { Error = "ERROR", Success = "SUCCESS", } -type CommandSuccessResult = { +export type CommandSuccessResult = { status: CommandResultStatus.Success; data: Data; }; diff --git a/packages/device-management-kit/src/api/types.ts b/packages/device-management-kit/src/api/types.ts index d0a72831c..ad23c71a1 100644 --- a/packages/device-management-kit/src/api/types.ts +++ b/packages/device-management-kit/src/api/types.ts @@ -16,6 +16,7 @@ export type { Command } from "@api/command/Command"; export type { CommandErrorResult, CommandResult, + CommandSuccessResult, } from "@api/command/model/CommandResult"; export type { SendCommandUseCaseArgs } from "@api/command/use-case/SendCommandUseCase"; export type { DeviceModelId } from "@api/device/DeviceModel"; From 4ab77ac19f6f94e6c163e092f7ec631b1477809a Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 27 Dec 2024 18:24:25 +0100 Subject: [PATCH 02/11] :sparkles: (signer-utils): Create CommandErrorHelper --- .changeset/breezy-plums-run.md | 5 ++ packages/signer/signer-utils/src/index.ts | 1 + .../src/utils/CommandErrorHelper.test.ts | 62 +++++++++++++++++++ .../src/utils/CommandErrorHelper.ts | 44 +++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 .changeset/breezy-plums-run.md create mode 100644 packages/signer/signer-utils/src/utils/CommandErrorHelper.test.ts create mode 100644 packages/signer/signer-utils/src/utils/CommandErrorHelper.ts diff --git a/.changeset/breezy-plums-run.md b/.changeset/breezy-plums-run.md new file mode 100644 index 000000000..4a8c713e0 --- /dev/null +++ b/.changeset/breezy-plums-run.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/signer-utils": patch +--- + +Create CommandErrorHelper to handle command errors diff --git a/packages/signer/signer-utils/src/index.ts b/packages/signer/signer-utils/src/index.ts index a83fa9315..69f7253b8 100644 --- a/packages/signer/signer-utils/src/index.ts +++ b/packages/signer/signer-utils/src/index.ts @@ -1 +1,2 @@ +export * from "./utils/CommandErrorHelper"; export * from "./utils/DerivationPathUtils"; diff --git a/packages/signer/signer-utils/src/utils/CommandErrorHelper.test.ts b/packages/signer/signer-utils/src/utils/CommandErrorHelper.test.ts new file mode 100644 index 000000000..4e85ca302 --- /dev/null +++ b/packages/signer/signer-utils/src/utils/CommandErrorHelper.test.ts @@ -0,0 +1,62 @@ +import { + ApduResponse, + CommandResultFactory, + GlobalCommandErrorHandler, +} from "@ledgerhq/device-management-kit"; + +import { CommandErrorHelper } from "./CommandErrorHelper"; + +describe("CommandErrorHelper", () => { + it("should return the correct error args and call factory when error is found", () => { + // given + const errors = { + "4224": { message: "An error occurred" }, + }; + const errorFactory = jest.fn(); + const helper = new CommandErrorHelper(errors, errorFactory); + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x42, 0x24]), + data: Uint8Array.from([]), + }); + // when + helper.getError(apduResponse); + // then + expect(errorFactory).toHaveBeenNthCalledWith(1, { + ...errors["4224"], + errorCode: "4224", + }); + }); + it("should return a global error when no error is found", () => { + // given + const errors = {}; + const errorFactory = jest.fn(); + const helper = new CommandErrorHelper(errors, errorFactory); + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x55, 0x15]), + data: Uint8Array.from([]), + }); + // when + const error = helper.getError(apduResponse); + // then + expect(errorFactory).toHaveBeenCalledTimes(0); + expect(error).toStrictEqual( + CommandResultFactory({ + error: GlobalCommandErrorHandler.handle(apduResponse), + }), + ); + }); + it("should return undefined if success apdu response", () => { + // given + const errors = {}; + const errorFactory = jest.fn(); + const helper = new CommandErrorHelper(errors, errorFactory); + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }); + // when + const error = helper.getError(apduResponse); + // then + expect(error).toBeUndefined(); + }); +}); diff --git a/packages/signer/signer-utils/src/utils/CommandErrorHelper.ts b/packages/signer/signer-utils/src/utils/CommandErrorHelper.ts new file mode 100644 index 000000000..dff95b60b --- /dev/null +++ b/packages/signer/signer-utils/src/utils/CommandErrorHelper.ts @@ -0,0 +1,44 @@ +import { + ApduParser, + type ApduResponse, + type CommandErrorArgs, + type CommandErrors, + type CommandResult, + CommandResultFactory, + CommandUtils, + type DeviceExchangeError, + GlobalCommandErrorHandler, + isCommandErrorCode, +} from "@ledgerhq/device-management-kit"; + +export class CommandErrorHelper { + constructor( + private readonly _errors: CommandErrors, + private readonly _errorFactory: ( + args: CommandErrorArgs, + ) => DeviceExchangeError, + private readonly _isSuccessResponse = CommandUtils.isSuccessResponse, + ) {} + + getError( + apduResponse: ApduResponse, + ): CommandResult | undefined { + const apduParser = new ApduParser(apduResponse); + const errorCode = apduParser.encodeToHexaString(apduResponse.statusCode); + + if (isCommandErrorCode(errorCode, this._errors)) { + return CommandResultFactory({ + error: this._errorFactory({ + ...this._errors[errorCode], + errorCode, + }), + }); + } + if (!this._isSuccessResponse(apduResponse)) { + return CommandResultFactory({ + error: GlobalCommandErrorHandler.handle(apduResponse), + }); + } + return undefined; + } +} From 758c7f9ab8969ff20ec2647c8740b4a93d271650 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 27 Dec 2024 18:30:34 +0100 Subject: [PATCH 03/11] :sparkles: (signer-btc): Use of CommandErrorHelper --- .changeset/young-horses-wonder.md | 5 + .../command/ContinueCommand.test.ts | 17 +- .../app-binder/command/ContinueCommand.ts | 42 ++++- .../GetExtendedPublicKeyCommand.test.ts | 12 +- .../command/GetExtendedPublicKeyCommand.ts | 69 +++++--- .../GetMasterFingerprintCommand.test.ts | 2 +- .../command/GetMasterFingerprintCommand.ts | 60 ++++--- .../command/GetWalletAddressCommand.test.ts | 12 +- .../command/GetWalletAddressCommand.ts | 73 ++++---- .../command/RegisterWalletAddressCommand.ts | 58 ++++--- .../command/SignMessageCommand.test.ts | 79 +-------- .../app-binder/command/SignMessageCommand.ts | 110 ++++-------- .../app-binder/command/SignPsbtCommand.ts | 40 +++-- .../command/utils/bitcoinAppError.test.ts | 35 ++-- .../command/utils/bitcoinAppErrors.ts | 28 ++-- .../internal/utils/BtcCommandUtils.test.ts | 156 ++++++++++++++++++ .../src/internal/utils/BtcCommandUtils.ts | 72 ++++++++ .../src/internal/utils/CommandUtils.test.ts | 63 ------- .../src/internal/utils/CommandUtils.ts | 21 --- 19 files changed, 511 insertions(+), 443 deletions(-) create mode 100644 .changeset/young-horses-wonder.md create mode 100644 packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.test.ts create mode 100644 packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts delete mode 100644 packages/signer/signer-btc/src/internal/utils/CommandUtils.test.ts delete mode 100644 packages/signer/signer-btc/src/internal/utils/CommandUtils.ts diff --git a/.changeset/young-horses-wonder.md b/.changeset/young-horses-wonder.md new file mode 100644 index 000000000..5cce71ef0 --- /dev/null +++ b/.changeset/young-horses-wonder.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-bitcoin": patch +--- + +Use CommandErrorHelper in BTC commands 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 19ddb619c..9cea0920d 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,9 @@ import { ApduResponse, CommandResultFactory, - 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"; @@ -26,21 +24,10 @@ 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, parser); + const command = new ContinueCommand(defaultArgs); // when const apdu = command.getApdu(); // then @@ -51,7 +38,7 @@ describe("ContinueCommand", (): void => { describe("parseResponse", () => { it("should return the APDU response if it's a continue response", () => { // given - const command = new ContinueCommand(defaultArgs, parser); + const command = new ContinueCommand(defaultArgs); const continueResponseData = new Uint8Array([0x01, 0x02, 0x03, 0x04]); const apduResponse = new ApduResponse({ statusCode: SW_INTERRUPTED_EXECUTION, 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 5379a9cf4..983548e7f 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 @@ -4,20 +4,38 @@ import { type ApduResponse, type Command, type CommandResult, + CommandResultFactory, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; + +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export type ContinueCommandArgs = { payload: Uint8Array; }; -export class ContinueCommand - implements Command +export type ContinueCommandResponse = ApduResponse; + +export class ContinueCommand + implements + Command { constructor( - private readonly args: ContinueCommandArgs, - private readonly parseFn: ( - response: ApduResponse, - ) => CommandResult, + private readonly _args: ContinueCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + ContinueCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), ) {} getApdu(): Apdu { @@ -27,11 +45,17 @@ export class ContinueCommand p1: 0x00, p2: 0x00, }) - .addBufferToData(this.args.payload) + .addBufferToData(this._args.payload) .build(); } - parseResponse(response: ApduResponse): CommandResult { - return this.parseFn(response); + parseResponse( + response: ApduResponse, + ): CommandResult { + return Maybe.fromNullable(this._errorHelper.getError(response)).orDefault( + CommandResultFactory({ + data: response, + }), + ); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts index cee126600..a497a91f0 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts @@ -148,13 +148,11 @@ describe("GetExtendedPublicKeyCommand", () => { const result = command.parseResponse(response); // THEN - if (!isSuccessCommandResult(result)) { - expect(result.error).toEqual( - new InvalidStatusWordError("Invalid response length"), - ); - } else { - fail("Expected an error, but the result was successful"); - } + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response length"), + }), + ); }); }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts index e119824c7..b15f0461f 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts @@ -8,11 +8,20 @@ import { type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; -import { DerivationPathUtils } from "@ledgerhq/signer-utils"; +import { + CommandErrorHelper, + DerivationPathUtils, +} from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; + +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; const STATUS_CODE_LENGTH = 2; @@ -29,13 +38,24 @@ export class GetExtendedPublicKeyCommand implements Command< GetExtendedPublicKeyCommandResponse, - GetExtendedPublicKeyCommandArgs + GetExtendedPublicKeyCommandArgs, + BtcErrorCodes > { - constructor(private readonly args: GetExtendedPublicKeyCommandArgs) {} + constructor( + private readonly _args: GetExtendedPublicKeyCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + GetExtendedPublicKeyCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { - const { checkOnDevice, derivationPath } = this.args; + const { checkOnDevice, derivationPath } = this._args; const getExtendedPublicKeyArgs: ApduBuilderArgs = { cla: 0xe1, @@ -58,31 +78,28 @@ export class GetExtendedPublicKeyCommand parseResponse( response: ApduResponse, - ): CommandResult { - const parser = new ApduParser(response); + ): CommandResult { + return Maybe.fromNullable( + this._errorHelper.getError(response), + ).orDefaultLazy(() => { + const parser = new ApduParser(response); + const length = parser.getUnparsedRemainingLength() - STATUS_CODE_LENGTH; - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } + if (length <= 0) { + return CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response length"), + }); + } - const length = parser.getUnparsedRemainingLength() - STATUS_CODE_LENGTH; + const extendedPublicKey = parser.encodeToString( + parser.extractFieldByLength(length), + ); - if (length <= 0) { return CommandResultFactory({ - error: new InvalidStatusWordError("Invalid response length"), + data: { + extendedPublicKey, + }, }); - } - - const extendedPublicKey = parser.encodeToString( - parser.extractFieldByLength(length), - ); - - return CommandResultFactory({ - data: { - extendedPublicKey, - }, }); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.test.ts index 5b33a4a5f..f30b2c6e1 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.test.ts @@ -47,7 +47,7 @@ describe("GetMasterFingerprintCommand", () => { expect(result).toEqual( CommandResultFactory({ data: { - masterFingerprint: "828dc2f3", + masterFingerprint: Uint8Array.from([0x82, 0x8d, 0xc2, 0xf3]), }, }), ); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.ts index 14796590e..4f9882c01 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.ts @@ -8,20 +8,37 @@ import { type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; + +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; const MASTER_FINGERPRINT_LENGTH = 4; type GetMasterFingerprintCommandResponse = { - masterFingerprint: string; + masterFingerprint: Uint8Array; }; export class GetMasterFingerprintCommand - implements Command + implements Command { + constructor( + private readonly _errorHelper = new CommandErrorHelper< + GetMasterFingerprintCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { const getMasterFingerprintArgs: ApduBuilderArgs = { cla: 0xe1, @@ -34,29 +51,26 @@ export class GetMasterFingerprintCommand parseResponse( response: ApduResponse, - ): CommandResult { - const parser = new ApduParser(response); + ): CommandResult { + return Maybe.fromNullable( + this._errorHelper.getError(response), + ).orDefaultLazy(() => { + const parser = new ApduParser(response); - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } + const masterFingerprint = parser.extractFieldByLength( + MASTER_FINGERPRINT_LENGTH, + ); + if (!masterFingerprint) { + return CommandResultFactory({ + error: new InvalidStatusWordError("Master fingerprint is missing"), + }); + } - if (!parser.testMinimalLength(MASTER_FINGERPRINT_LENGTH)) { return CommandResultFactory({ - error: new InvalidStatusWordError("Master fingerprint is missing"), + data: { + masterFingerprint, + }, }); - } - - const masterFingerprint = parser.encodeToHexaString( - parser.extractFieldByLength(MASTER_FINGERPRINT_LENGTH), - ); - - return CommandResultFactory({ - data: { - masterFingerprint, - }, }); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts index 9f3688102..16e26aeb4 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts @@ -4,10 +4,7 @@ import { isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; -import { - BitcoinAppCommandError, - bitcoinAppErrors, -} from "./utils/bitcoinAppErrors"; +import { BTC_APP_ERRORS, BtcAppCommandError } from "./utils/bitcoinAppErrors"; import { GetWalletAddressCommand, type GetWalletAddressCommandArgs, @@ -113,10 +110,9 @@ describe("GetWalletAddressCommand", () => { expect(isSuccessCommandResult(result)).toBe(false); if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(BitcoinAppCommandError); - const error = result.error as BitcoinAppCommandError; - expect(error.customErrorCode).toBe("6985"); - const expectedErrorInfo = bitcoinAppErrors["6985"]; + expect(result.error).toBeInstanceOf(BtcAppCommandError); + const error = result.error as BtcAppCommandError; + const expectedErrorInfo = BTC_APP_ERRORS["6985"]; expect(expectedErrorInfo).toBeDefined(); if (expectedErrorInfo) { expect(error.message).toBe(expectedErrorInfo.message); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts index ab549e6d6..9750d6f3e 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts @@ -6,17 +6,18 @@ import { type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, InvalidStatusWordError, - isCommandErrorCode, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; import { - BitcoinAppCommandError, - bitcoinAppErrors, + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, } from "./utils/bitcoinAppErrors"; export type GetWalletAddressCommandResponse = { @@ -33,9 +34,23 @@ export type GetWalletAddressCommandArgs = { export class GetWalletAddressCommand implements - Command + Command< + GetWalletAddressCommandResponse, + GetWalletAddressCommandArgs, + BtcErrorCodes + > { - constructor(private readonly args: GetWalletAddressCommandArgs) {} + constructor( + private readonly args: GetWalletAddressCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + GetWalletAddressCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { return new ApduBuilder({ @@ -54,37 +69,25 @@ export class GetWalletAddressCommand parseResponse( response: ApduResponse, - ): CommandResult { - const parser = new ApduParser(response); - const errorCode = parser.encodeToHexaString(response.statusCode); - if (isCommandErrorCode(errorCode, bitcoinAppErrors)) { - return CommandResultFactory({ - error: new BitcoinAppCommandError({ - ...bitcoinAppErrors[errorCode], - errorCode, - }), - }); - } + ): CommandResult { + return Maybe.fromNullable( + this._errorHelper.getError(response), + ).orDefaultLazy(() => { + const parser = new ApduParser(response); + if (response.data.length === 0) { + return CommandResultFactory({ + error: new InvalidStatusWordError( + "Failed to extract address from response", + ), + }); + } - if (!CommandUtils.isSuccessResponse(response)) { + const address = parser.encodeToString(response.data); return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), + data: { + address, + }, }); - } - - if (response.data.length === 0) { - return CommandResultFactory({ - error: new InvalidStatusWordError( - "Failed to extract address from response", - ), - }); - } - - const address = parser.encodeToString(response.data); - return CommandResultFactory({ - data: { - address, - }, }); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/RegisterWalletAddressCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/RegisterWalletAddressCommand.ts index 22e0edee5..8f169c416 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/RegisterWalletAddressCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/RegisterWalletAddressCommand.ts @@ -5,12 +5,18 @@ import { type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export type RegisterWalletAddressCommandArgs = { walletPolicy: Uint8Array; @@ -27,10 +33,21 @@ export class RegisterWalletAddressCommand implements Command< RegisterWalletAddressCommandResponse, - RegisterWalletAddressCommandArgs + RegisterWalletAddressCommandArgs, + BtcErrorCodes > { - constructor(private readonly _args: RegisterWalletAddressCommandArgs) {} + constructor( + private readonly _args: RegisterWalletAddressCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + RegisterWalletAddressCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu() { const builder = new ApduBuilder({ @@ -45,26 +62,25 @@ export class RegisterWalletAddressCommand } parseResponse( response: ApduResponse, - ): CommandResult { - const parser = new ApduParser(response); + ): CommandResult { + return Maybe.fromNullable( + this._errorHelper.getError(response), + ).orDefaultLazy(() => { + const parser = new ApduParser(response); - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } - const walletId = parser.extractFieldByLength(RESPONSE_BUFFER_LENGTH); - const walletHmac = parser.extractFieldByLength(RESPONSE_BUFFER_LENGTH); - if (!walletId || !walletHmac) { + const walletId = parser.extractFieldByLength(RESPONSE_BUFFER_LENGTH); + const walletHmac = parser.extractFieldByLength(RESPONSE_BUFFER_LENGTH); + if (!walletId || !walletHmac) { + return CommandResultFactory({ + error: new InvalidStatusWordError("Data mismatch"), + }); + } return CommandResultFactory({ - error: new InvalidStatusWordError("Data mismatch"), + data: { + walletId, + walletHmac, + }, }); - } - return CommandResultFactory({ - data: { - walletId, - walletHmac, - }, }); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.test.ts index b5c97023c..eb6b64c3b 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.test.ts @@ -1,7 +1,6 @@ import { ApduResponse, CommandResultFactory, - InvalidStatusWordError, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; @@ -120,27 +119,6 @@ describe("SignMessageCommand", (): void => { ); }); - it("should return correct response after successful signing", () => { - // given - const command = new SignMessageCommand(defaultArgs); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: getResponse(), - }); - // 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 SignMessageCommand(defaultArgs); @@ -157,24 +135,6 @@ describe("SignMessageCommand", (): void => { } }); - it("should return an error when the response data is empty", () => { - // given - const command = new SignMessageCommand(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 not empty", () => { // given const command = new SignMessageCommand(defaultArgs); @@ -185,46 +145,9 @@ describe("SignMessageCommand", (): void => { // 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", () => { - // given - const command = new SignMessageCommand(defaultArgs); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: getResponse({ omitR: true }), - }); - // 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", () => { - // given - const command = new SignMessageCommand(defaultArgs); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: getResponse({ omitS: true }), - }); - // when - const response = command.parseResponse(apduResponse); - // then expect(response).toStrictEqual( CommandResultFactory({ - error: new InvalidStatusWordError("S is missing"), + data: apduResponse, }), ); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.ts index 7815112ea..a9cde2064 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.ts @@ -1,31 +1,27 @@ import { type Apdu, ApduBuilder, - ApduParser, type ApduResponse, type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, - InvalidStatusWordError, - isCommandErrorCode, } from "@ledgerhq/device-management-kit"; -import { DerivationPathUtils } from "@ledgerhq/signer-utils"; +import { + CommandErrorHelper, + DerivationPathUtils, +} from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; -import { type Signature } from "@api/model/Signature"; import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants"; -import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; import { encodeVarint } from "@internal/utils/Varint"; import { - BitcoinAppCommandError, - bitcoinAppErrors, + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, } from "./utils/bitcoinAppErrors"; -const R_LENGTH = 32; -const S_LENGTH = 32; - export type SignMessageCommandArgs = { /** * The BIP32 path (e.g., "m/44'/0'/0'/0/0") @@ -41,19 +37,26 @@ export type SignMessageCommandArgs = { readonly messageMerkleRoot: Uint8Array; }; -export type SignMessageCommandResponse = Signature | ApduResponse; +export type SignMessageCommandResponse = ApduResponse; export class SignMessageCommand - implements Command + implements + Command { - readonly args: SignMessageCommandArgs; - - constructor(args: SignMessageCommandArgs) { - this.args = args; - } + constructor( + private readonly _args: SignMessageCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + SignMessageCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { - const { derivationPath, messageLength, messageMerkleRoot } = this.args; + const { derivationPath, messageLength, messageMerkleRoot } = this._args; const builder = new ApduBuilder({ cla: 0xe1, @@ -76,66 +79,9 @@ export class SignMessageCommand parseResponse( apduResponse: ApduResponse, - ): CommandResult { - if (BtcCommandUtils.isContinueResponse(apduResponse)) { - return CommandResultFactory({ - data: apduResponse, - }); - } - - if (!CommandUtils.isSuccessResponse(apduResponse)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(apduResponse), - }); - } - - const parser = new ApduParser(apduResponse); - const errorCode = parser.encodeToHexaString(apduResponse.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, - }, - }); + ): CommandResult { + return Maybe.fromNullable( + this._errorHelper.getError(apduResponse), + ).orDefault(CommandResultFactory({ data: apduResponse })); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts index 95ea0873a..5484c8895 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts @@ -5,11 +5,17 @@ import { type Command, type CommandResult, CommandResultFactory, - GlobalCommandErrorHandler, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants"; -import { CommandUtils } from "@internal/utils/CommandUtils"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export type SignPsbtCommandArgs = { globalCommitments: Uint8Array; @@ -19,12 +25,23 @@ export type SignPsbtCommandArgs = { walletHmac: Uint8Array; }; -type SignPsbtCommandResponse = void; +type SignPsbtCommandResponse = ApduResponse; export class SignPsbtCommand - implements Command + implements + Command { - constructor(private _args: SignPsbtCommandArgs) {} + constructor( + private readonly _args: SignPsbtCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + SignPsbtCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { const builder = new ApduBuilder({ @@ -51,14 +68,9 @@ export class SignPsbtCommand } parseResponse( response: ApduResponse, - ): CommandResult { - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } - return CommandResultFactory({ - data: undefined, - }); + ): CommandResult { + return Maybe.fromNullable(this._errorHelper.getError(response)).orDefault( + CommandResultFactory({ data: response }), + ); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts index 08c392fad..41325cc69 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts @@ -1,12 +1,12 @@ import { DeviceExchangeError } from "@ledgerhq/device-management-kit"; import { - BitcoinAppCommandError, - type BitcoinAppErrorCodes, - bitcoinAppErrors, + BTC_APP_ERRORS, + BtcAppCommandError, + type BtcErrorCodes, } from "./bitcoinAppErrors"; -describe("BitcoinAppCommandError", () => { +describe("BtcAppCommandError", () => { afterEach(() => { jest.resetAllMocks(); }); @@ -16,7 +16,7 @@ describe("BitcoinAppCommandError", () => { }); it("should be an instance of DeviceExchangeError", () => { - const error = new BitcoinAppCommandError({ + const error = new BtcAppCommandError({ message: "Test error message", errorCode: "6985", }); @@ -26,7 +26,7 @@ describe("BitcoinAppCommandError", () => { it("should set the correct message when provided", () => { const customMessage = "Custom error message"; - const error = new BitcoinAppCommandError({ + const error = new BtcAppCommandError({ message: customMessage, errorCode: "6985", }); @@ -34,35 +34,26 @@ describe("BitcoinAppCommandError", () => { expect(error.message).toBe(customMessage); }); - it("should set the default message when none is provided", () => { - const error = new BitcoinAppCommandError({ - message: undefined, - errorCode: "6985", - }); - - expect(error.message).toBe("An error occurred during device exchange."); - }); - it("should set the correct customErrorCode", () => { - const errorCode: BitcoinAppErrorCodes = "6A86"; - const error = new BitcoinAppCommandError({ + const errorCode: BtcErrorCodes = "6A86"; + const error = new BtcAppCommandError({ message: "Either P1 or P2 is incorrect", errorCode, }); - expect(error.customErrorCode).toBe(errorCode); + expect(error.errorCode).toBe(errorCode); }); it("should correlate error codes with messages from bitcoinAppErrors", () => { - const errorCode: BitcoinAppErrorCodes = "6E00"; - const expectedMessage = bitcoinAppErrors[errorCode].message; + const errorCode: BtcErrorCodes = "6E00"; + const expectedMessage = BTC_APP_ERRORS[errorCode].message; - const error = new BitcoinAppCommandError({ + const error = new BtcAppCommandError({ message: expectedMessage, errorCode, }); - expect(error.customErrorCode).toBe(errorCode); + expect(error.errorCode).toBe(errorCode); expect(error.message).toBe(expectedMessage); expect(error).toBeInstanceOf(DeviceExchangeError); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts index 3fd64003f..d7d91bf47 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts @@ -1,12 +1,10 @@ -//temp file, will be changed in a specific PR - import { + type CommandErrorArgs, type CommandErrors, DeviceExchangeError, - type DmkError, } from "@ledgerhq/device-management-kit"; -export type BitcoinAppErrorCodes = +export type BtcErrorCodes = | "6985" | "6A86" | "6A87" @@ -16,7 +14,7 @@ export type BitcoinAppErrorCodes = | "B007" | "B008"; -export const bitcoinAppErrors: CommandErrors = { +export const BTC_APP_ERRORS: CommandErrors = { "6985": { message: "Rejected by user" }, "6A86": { message: "Either P1 or P2 is incorrect" }, "6A87": { message: "Lc or minimum APDU length is incorrect" }, @@ -27,18 +25,12 @@ export const bitcoinAppErrors: CommandErrors = { B008: { message: "Invalid signature or HMAC" }, }; -export class BitcoinAppCommandError - extends DeviceExchangeError - implements DmkError -{ - public readonly customErrorCode?: BitcoinAppErrorCodes; - - constructor(args: { message?: string; errorCode?: BitcoinAppErrorCodes }) { - super({ - tag: "BitcoinAppCommandError", - message: args.message || "An error occurred during device exchange.", - errorCode: undefined, - }); - this.customErrorCode = args.errorCode; +export class BtcAppCommandError extends DeviceExchangeError { + constructor(args: CommandErrorArgs) { + super({ tag: "BtcAppCommandError", ...args }); } } + +export const BtcAppCommandErrorFactory = ( + args: CommandErrorArgs, +) => new BtcAppCommandError(args); diff --git a/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.test.ts b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.test.ts new file mode 100644 index 000000000..d29fcefb4 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.test.ts @@ -0,0 +1,156 @@ +import { + ApduResponse, + CommandResultFactory, + type CommandSuccessResult, + InvalidStatusWordError, +} from "@ledgerhq/device-management-kit"; + +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; + +const SIGNATURE_V_RESPONSE = new Uint8Array([0x1b]); +const SIGNATURE_R_RESPONSE = new Uint8Array([ + 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, +]); +const SIGNATURE_S_RESPONSE = new Uint8Array([ + 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, +]); + +const SIGNATURE_RESPONSE = new Uint8Array([ + ...SIGNATURE_V_RESPONSE, + ...SIGNATURE_R_RESPONSE, + ...SIGNATURE_S_RESPONSE, +]); + +describe("BtcCommandUtils", () => { + describe("isSuccessResponse", () => { + it("should return true if statusCode is e000", () => { + // given + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isSuccessResponse(apduResponse); + // then + expect(result).toBe(true); + }); + it("should return true if statusCode is 9000", () => { + // given + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isSuccessResponse(apduResponse); + // then + expect(result).toBe(true); + }); + it("should return false if statusCode is not allowed", () => { + // given + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x43, 0x04]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isSuccessResponse(apduResponse); + // then + expect(result).toBe(false); + }); + }); + describe("isContinueResponse", () => { + it("should return true if statusCode is e000", () => { + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isContinueResponse(apduResponse); + // then + expect(result).toBe(true); + }); + it("should return false if statusCode is 9000", () => { + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isContinueResponse(apduResponse); + // then + expect(result).toBe(false); + }); + }); + describe("getSignature", () => { + it("should return an error if 'v' is missing", () => { + // given + const result = CommandResultFactory({ + data: new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: new Uint8Array([]), + }), + }); + + // when + const signature = BtcCommandUtils.getSignature( + result as CommandSuccessResult, + ); + + // then + expect(signature).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("V is missing"), + }), + ); + }); + + it("should return an error if 's' is missing", () => { + // given + const result = CommandResultFactory({ + data: new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: new Uint8Array([ + ...SIGNATURE_V_RESPONSE, + ...SIGNATURE_R_RESPONSE, + ]), + }), + }); + // when + const signature = BtcCommandUtils.getSignature( + result as CommandSuccessResult, + ); + // then + expect(signature).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("S is missing"), + }), + ); + }); + + it("should return a signature if v, r, and s are present", () => { + // given + const result = CommandResultFactory({ + data: new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: SIGNATURE_RESPONSE, + }), + }); + // when + const signature = BtcCommandUtils.getSignature( + result as CommandSuccessResult, + ); + // then + expect(signature).toStrictEqual( + CommandResultFactory({ + data: { + v: 27, + r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", + s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", + }, + }), + ); + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts new file mode 100644 index 000000000..c47754ed2 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts @@ -0,0 +1,72 @@ +import { + ApduParser, + type ApduResponse, + type CommandResult, + CommandResultFactory, + type CommandSuccessResult, + CommandUtils as DmkCommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-management-kit"; + +import { type Signature } from "@api/model/Signature"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { SW_INTERRUPTED_EXECUTION } from "@internal/app-binder/command/utils/constants"; + +const R_LENGTH = 32; +const S_LENGTH = 32; + +export class BtcCommandUtils { + static isContinueResponse(response: ApduResponse) { + return ( + response.statusCode[0] === SW_INTERRUPTED_EXECUTION[0] && + response.statusCode[1] === SW_INTERRUPTED_EXECUTION[1] + ); + } + static isSuccessResponse(response: ApduResponse) { + return ( + DmkCommandUtils.isSuccessResponse(response) || + BtcCommandUtils.isContinueResponse(response) + ); + } + + static getSignature( + result: CommandSuccessResult, + ): CommandResult { + const parser = new ApduParser(result.data); + + const v = parser.extract8BitUInt(); + if (v === undefined) { + return CommandResultFactory({ + error: new InvalidStatusWordError("V is missing"), + }); + } + + const r = parser.encodeToHexaString( + parser.extractFieldByLength(R_LENGTH), + true, + ); + if (!r) { + return CommandResultFactory({ + error: new InvalidStatusWordError("R is missing"), + }); + } + + 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, + }, + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/utils/CommandUtils.test.ts b/packages/signer/signer-btc/src/internal/utils/CommandUtils.test.ts deleted file mode 100644 index e938e493c..000000000 --- a/packages/signer/signer-btc/src/internal/utils/CommandUtils.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ApduResponse } from "@ledgerhq/device-management-kit"; - -import { CommandUtils } from "@internal/utils/CommandUtils"; - -describe("CommandUtils", () => { - describe("isSuccessResponse", () => { - it("should return true if statusCode is e000", () => { - // given - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0xe0, 0x00]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isSuccessResponse(apduResponse); - // then - expect(result).toBe(true); - }); - it("should return true if statusCode is 9000", () => { - // given - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0x90, 0x00]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isSuccessResponse(apduResponse); - // then - expect(result).toBe(true); - }); - it("should return false if statusCode is not allowed", () => { - // given - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0x43, 0x04]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isSuccessResponse(apduResponse); - // then - expect(result).toBe(false); - }); - }); - describe("isSuccessResponse", () => { - it("should return true if statusCode is e000", () => { - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0xe0, 0x00]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isContinueResponse(apduResponse); - // then - expect(result).toBe(true); - }); - it("should return false if statusCode is 9000", () => { - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0x90, 0x00]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isContinueResponse(apduResponse); - // then - expect(result).toBe(false); - }); - }); -}); diff --git a/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts b/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts deleted file mode 100644 index 7083d7cb5..000000000 --- a/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - type ApduResponse, - CommandUtils as DmkCommandUtils, -} from "@ledgerhq/device-management-kit"; - -import { SW_INTERRUPTED_EXECUTION } from "@internal/app-binder/command/utils/constants"; - -export class CommandUtils { - static isContinueResponse(response: ApduResponse) { - return ( - response.statusCode[0] === SW_INTERRUPTED_EXECUTION[0] && - response.statusCode[1] === SW_INTERRUPTED_EXECUTION[1] - ); - } - static isSuccessResponse(response: ApduResponse) { - return ( - DmkCommandUtils.isSuccessResponse(response) || - CommandUtils.isContinueResponse(response) - ); - } -} From 4d156b7b66921f3d40397f7a00021adbe6764ae1 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 27 Dec 2024 18:36:54 +0100 Subject: [PATCH 04/11] :recycle: (signer-btc): Create ContinueTask & use it for SignMessage --- .../GetExtendedPublicKeyDeviceActionTypes.ts | 6 +- ...ype.ts => SignMessageDeviceActionTypes.ts} | 5 +- .../SignMessage/SignMessageDeviceAction.ts | 5 +- .../app-binder/task/ContinueTask.test.ts | 113 +++++++ .../internal/app-binder/task/ContinueTask.ts | 81 +++++ .../app-binder/task/SignMessageTask.test.ts | 311 +++++------------- .../app-binder/task/SignMessageTask.ts | 180 +--------- .../sign-message/SignMessageUseCase.ts | 2 +- 8 files changed, 291 insertions(+), 412 deletions(-) rename packages/signer/signer-btc/src/api/app-binder/{SignMessageDeviceActionType.ts => SignMessageDeviceActionTypes.ts} (85%) create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts diff --git a/packages/signer/signer-btc/src/api/app-binder/GetExtendedPublicKeyDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/GetExtendedPublicKeyDeviceActionTypes.ts index 204dc5aae..436238cbe 100644 --- a/packages/signer/signer-btc/src/api/app-binder/GetExtendedPublicKeyDeviceActionTypes.ts +++ b/packages/signer/signer-btc/src/api/app-binder/GetExtendedPublicKeyDeviceActionTypes.ts @@ -10,6 +10,7 @@ import { type GetExtendedPublicKeyCommandArgs, type GetExtendedPublicKeyCommandResponse, } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; type GetExtendedPublicKeyDARequiredInteraction = | UserInteractionRequired.None @@ -18,14 +19,15 @@ type GetExtendedPublicKeyDARequiredInteraction = export type GetExtendedPublicKeyDAOutput = SendCommandInAppDAOutput; -export type GetExtendedPublicKeyDAError = SendCommandInAppDAError; +export type GetExtendedPublicKeyDAError = + SendCommandInAppDAError; export type GetExtendedDAIntermediateValue = SendCommandInAppDAIntermediateValue; export type GetExtendedPublicKeyDAInput = GetExtendedPublicKeyCommandArgs; -export type GetExtendedPublicKeyReturnType = ExecuteDeviceActionReturnType< +export type GetExtendedPublicKeyDAReturnType = ExecuteDeviceActionReturnType< GetExtendedPublicKeyDAOutput, GetExtendedPublicKeyDAError, GetExtendedDAIntermediateValue diff --git a/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts b/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionTypes.ts similarity index 85% rename from packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts rename to packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionTypes.ts index 289334468..300e48995 100644 --- a/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts +++ b/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionTypes.ts @@ -8,6 +8,7 @@ import { } from "@ledgerhq/device-management-kit"; import { type Signature } from "@api/model/Signature"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; export type SignMessageDAOutput = Signature; @@ -16,7 +17,9 @@ export type SignMessageDAInput = { readonly message: string; }; -export type SignMessageDAError = OpenAppDAError | CommandErrorResult["error"]; +export type SignMessageDAError = + | OpenAppDAError + | CommandErrorResult["error"]; type SignMessageDARequiredInteraction = | OpenAppDARequiredInteraction 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 index 9a5060101..9590a15fe 100644 --- 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 @@ -18,8 +18,9 @@ import { type SignMessageDAIntermediateValue, type SignMessageDAInternalState, type SignMessageDAOutput, -} from "@api/app-binder/SignMessageDeviceActionType"; +} from "@api/app-binder/SignMessageDeviceActionTypes"; import { type Signature } from "@api/model/Signature"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { SendSignMessageTask, type SendSignMessageTaskArgs, @@ -28,7 +29,7 @@ import { export type MachineDependencies = { readonly signMessage: (arg0: { input: SendSignMessageTaskArgs; - }) => Promise>; + }) => Promise>; }; export type ExtractMachineDependencies = ( diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts new file mode 100644 index 000000000..c382515f4 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts @@ -0,0 +1,113 @@ +import { + ApduResponse, + CommandResultFactory, + type DmkError, + type InternalApi, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; +import { type Either, Left, Right } from "purify-ts"; + +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { ContinueTask } from "@internal/app-binder/task/ContinueTask"; +import { type DataStore } from "@internal/data-store/model/DataStore"; + +describe("ContinueTask", () => { + const clientCommandInterpreter = { + getClientCommandPayload: jest.fn( + () => Right(Uint8Array.from([])) as Either, + ), + }; + const api = { + sendCommand: jest.fn(), + }; + const randomNumberOfClientCalls = Math.floor(Math.random() * 10 + 2); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it(`should call ${randomNumberOfClientCalls} times client interpreter and return success`, async () => { + // given + new Array(randomNumberOfClientCalls).fill(0).forEach((_) => { + api.sendCommand.mockReturnValueOnce( + CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }), + }), + ); + }); + api.sendCommand.mockReturnValueOnce( + CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }), + }), + ); + const fromResult = CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }), + }); + // when + const task = new ContinueTask( + api as unknown as InternalApi, + clientCommandInterpreter, + ); + await task.run({} as DataStore, fromResult); + // then + expect( + clientCommandInterpreter.getClientCommandPayload, + ).toHaveBeenCalledTimes(randomNumberOfClientCalls + 1); + }); + + it("should return an error if the client interpreter fails", async () => { + // given + const error = new UnknownDeviceExchangeError("Failed"); + clientCommandInterpreter.getClientCommandPayload.mockReturnValueOnce( + Left(error), + ); + const fromResult = CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }), + }); + // when + const task = new ContinueTask( + api as unknown as InternalApi, + clientCommandInterpreter, + ); + const result = await task.run({} as DataStore, fromResult); + // then + expect(api.sendCommand).toHaveBeenCalledTimes(0); + expect(result).toStrictEqual( + CommandResultFactory({ error: new UnknownDeviceExchangeError(error) }), + ); + }); + it("should return an error if send command fails", async () => { + // given + const error = new UnknownDeviceExchangeError("Failed"); + api.sendCommand.mockReturnValueOnce(CommandResultFactory({ error })); + const fromResult = CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }), + }); + // when + const task = new ContinueTask( + api as unknown as InternalApi, + clientCommandInterpreter, + ); + const result = await task.run({} as DataStore, fromResult); + // then + expect( + clientCommandInterpreter.getClientCommandPayload, + ).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(CommandResultFactory({ error })); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts new file mode 100644 index 000000000..f5034af9c --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts @@ -0,0 +1,81 @@ +import { + type ApduResponse, + type CommandResult, + CommandResultFactory, + type CommandSuccessResult, + type InternalApi, + isSuccessCommandResult, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; + +import { type ClientCommandContext } from "@internal/app-binder/command/client-command-handlers/ClientCommandHandlersTypes"; +import { + ContinueCommand, + type ContinueCommandResponse, +} from "@internal/app-binder/command/ContinueCommand"; +import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { type DataStore } from "@internal/data-store/model/DataStore"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; + +export class ContinueTask { + private readonly _clientCommandInterpreter: ClientCommandInterpreter; + + constructor( + private readonly _api: InternalApi, + clientCommandInterpreter?: ClientCommandInterpreter, + ) { + this._clientCommandInterpreter = + clientCommandInterpreter || new ClientCommandInterpreter(); + } + + async run( + dataStore: DataStore, + fromResult: CommandResult, + ): Promise> { + let currentResponse: CommandResult = + fromResult; + const commandHandlersContext: ClientCommandContext = { + dataStore, + queue: [], + yieldedResults: [], + }; + + while ( + this.isApduResult(currentResponse) && + BtcCommandUtils.isContinueResponse(currentResponse.data) + ) { + currentResponse = await this._clientCommandInterpreter + .getClientCommandPayload( + currentResponse.data.data, + commandHandlersContext, + ) + .caseOf({ + Left: (error) => + Promise.resolve( + CommandResultFactory({ + error: new UnknownDeviceExchangeError(error), + }), + ), + Right: (payload) => + this._api.sendCommand( + new ContinueCommand({ + payload, + }), + ), + }); + } + return currentResponse; + } + private isApduResult = ( + response: CommandResult, + ): response is CommandSuccessResult => { + return ( + isSuccessCommandResult(response) && + typeof response.data === "object" && + response.data !== null && + "statusCode" in response.data && + "data" in response.data + ); + }; +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts index c298233f4..0474ca51e 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts @@ -1,20 +1,18 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { - type ApduResponse, + ApduResponse, CommandResultFactory, CommandResultStatus, type InternalApi, InvalidStatusWordError, - isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; import { Left, Right } from "purify-ts"; import { type Signature } from "@api/model/Signature"; +import type { ClientCommandContext } from "@internal/app-binder/command/client-command-handlers/ClientCommandHandlersTypes"; import { ClientCommandHandlerError } from "@internal/app-binder/command/client-command-handlers/Errors"; +import { ContinueCommand } from "@internal/app-binder/command/ContinueCommand"; import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; +import { SignMessageCommand } from "@internal/app-binder/command/SignMessageCommand"; import { CHUNK_SIZE, ClientCommandCodes, @@ -33,8 +31,8 @@ const MERKLE_ROOT = new Uint8Array(SHA256_SIZE).fill(0x01); const SIGNATURE: Signature = { v: 27, - r: "0x1212121212121212121212121212121212121212121212121212121212121212", - s: "0x3434343434343434343434343434343434343434343434343434343434343434", + r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", + s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", }; const APDU_RESPONSE_YELD: ApduResponse = { @@ -42,44 +40,20 @@ const APDU_RESPONSE_YELD: ApduResponse = { data: new Uint8Array([ClientCommandCodes.YIELD]), }; -// Helper function to create a mock signature response -const getSignatureResponse = ({ - omitV = false, - omitR = false, - omitS = false, -}: { - omitV?: boolean; - omitR?: boolean; - omitS?: boolean; -} = {}) => - omitV - ? new Uint8Array([]) - : new Uint8Array([ - // v - ...(omitR ? [] : [0x1b]), - // r (32 bytes) unless omitted - ...(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, - ]), - // s (32 bytes) unless omitted - ...(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, - ]), - ]); - -const USER_DENIED_STATUS = new Uint8Array([0x69, 0x85]); +const SIGNATURE_APDU = new Uint8Array([ + 0x1b, 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, 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, +]); describe("SignMessageTask", () => { - const signatureResult = CommandResultFactory({ - data: SIGNATURE, + const signatureResult = CommandResultFactory({ + data: new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: SIGNATURE_APDU, + }), }); const apiMock = { sendCommand: jest.fn(), @@ -104,7 +78,14 @@ describe("SignMessageTask", () => { return MERKLE_ROOT; }); - (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(signatureResult); + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce( + CommandResultFactory({ + data: new ApduResponse({ + data: SIGNATURE_APDU, + statusCode: new Uint8Array([0x90, 0x00]), + }), + }), + ); // WHEN const result = await new SendSignMessageTask(apiMock, args).run(); @@ -171,19 +152,21 @@ describe("SignMessageTask", () => { const getClientCommandPayloadMock = jest .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") - .mockImplementation((request: Uint8Array, context: any) => { - const commandCode = request[0]; - if (commandCode === ClientCommandCodes.YIELD) { - // simulate YIELD command - context.yieldedResults.push(new Uint8Array([])); - return Right(new Uint8Array([0x00])); - } - if (commandCode === ClientCommandCodes.GET_PREIMAGE) { - // simulate GET_PREIMAGE command - return Right(PREIMAGE); - } - return Left(new ClientCommandHandlerError("error")); - }); + .mockImplementation( + (request: Uint8Array, context: ClientCommandContext) => { + const commandCode = request[0]; + if (commandCode === ClientCommandCodes.YIELD) { + // simulate YIELD command + context.yieldedResults.push(new Uint8Array([])); + return Right(new Uint8Array([0x00])); + } + if (commandCode === ClientCommandCodes.GET_PREIMAGE) { + // simulate GET_PREIMAGE command + return Right(PREIMAGE); + } + return Left(new ClientCommandHandlerError("error")); + }, + ); // WHEN const result = await new SendSignMessageTask(apiMock, args).run(); @@ -198,31 +181,25 @@ describe("SignMessageTask", () => { // check that sendCommand was called with the correct commands expect(apiMock.sendCommand).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ - args: { - derivationPath: DERIVATION_PATH, - messageLength: new TextEncoder().encode(EXACT_TWO_CHUNKS_MESSAGE) - .length, - messageMerkleRoot: MERKLE_ROOT, - }, + new SignMessageCommand({ + derivationPath: DERIVATION_PATH, + messageLength: new TextEncoder().encode(EXACT_TWO_CHUNKS_MESSAGE) + .length, + messageMerkleRoot: MERKLE_ROOT, }), ); expect(apiMock.sendCommand).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ - args: { - payload: new Uint8Array([0x00]), - }, + new ContinueCommand({ + payload: new Uint8Array([0x00]), }), ); expect(apiMock.sendCommand).toHaveBeenNthCalledWith( 3, - expect.objectContaining({ - args: { - payload: PREIMAGE, - }, + new ContinueCommand({ + payload: PREIMAGE, }), ); @@ -303,16 +280,18 @@ describe("SignMessageTask", () => { const getClientCommandPayloadMock = jest .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") - .mockImplementation((request: Uint8Array, context: any) => { - const commandCode = request[0]; - if (commandCode === ClientCommandCodes.YIELD) { - // simulate YIELD command - context.yieldedResults.push(new Uint8Array([])); - return Right(new Uint8Array([0x00])); - } - // no need GET_PREIMAGE since as it should fail before - return Left(new ClientCommandHandlerError("error")); - }); + .mockImplementation( + (request: Uint8Array, context: ClientCommandContext) => { + const commandCode = request[0]; + if (commandCode === ClientCommandCodes.YIELD) { + // simulate YIELD command + context.yieldedResults.push(new Uint8Array([])); + return Right(new Uint8Array([0x00])); + } + // no need GET_PREIMAGE since as it should fail before + return Left(new ClientCommandHandlerError("error")); + }, + ); // WHEN const result = await new SendSignMessageTask(apiMock, args).run(); @@ -326,30 +305,27 @@ describe("SignMessageTask", () => { // check that sendCommand was called with the correct commands expect(apiMock.sendCommand).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ - args: { - derivationPath: DERIVATION_PATH, - messageLength: new TextEncoder().encode(EXACT_TWO_CHUNKS_MESSAGE) - .length, - messageMerkleRoot: MERKLE_ROOT, - }, + new SignMessageCommand({ + derivationPath: DERIVATION_PATH, + messageLength: new TextEncoder().encode(EXACT_TWO_CHUNKS_MESSAGE) + .length, + messageMerkleRoot: MERKLE_ROOT, }), ); expect(apiMock.sendCommand).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ - args: { - payload: new Uint8Array([0x00]), - }, + new ContinueCommand({ + payload: new Uint8Array([0x00]), }), ); // check the final result - expect(result.status).toBe(CommandResultStatus.Error); - if (result.status === CommandResultStatus.Error) { - expect(result.error).toBeInstanceOf(InvalidStatusWordError); - } + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response from the device"), + }), + ); // check that getClientCommandPayload was called correctly expect(getClientCommandPayloadMock).toHaveBeenCalledTimes(1); @@ -360,143 +336,4 @@ describe("SignMessageTask", () => { ); }); }); - - describe("parseBitcoinSignatureResponse", () => { - let instance: SendSignMessageTask; - - beforeEach(() => { - instance = new SendSignMessageTask(apiMock, { - derivationPath: DERIVATION_PATH, - message: "test", - }); - }); - - it("should return a continuation response if it's a continue response", () => { - const apduResponse: ApduResponse = { - statusCode: SW_INTERRUPTED_EXECUTION, - data: new Uint8Array([ClientCommandCodes.YIELD]), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - expect(result.status).toBe(CommandResultStatus.Success); - if (isSuccessCommandResult(result)) { - expect(result.data).toEqual(apduResponse); - } - }); - - it("should return a global error if not success and not a known continuation", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x6a, 0x80]), - data: new Uint8Array([]), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeDefined(); - } - }); - - it("should return a bitcoin app command error if the error code matches a known bitcoin app error", () => { - const apduResponse: ApduResponse = { - statusCode: USER_DENIED_STATUS, - data: new Uint8Array([]), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeDefined(); - } - }); - - it("should return an error if 'v' is missing", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x90, 0x00]), - data: getSignatureResponse({ omitV: true }), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(InvalidStatusWordError); - expect(result).toStrictEqual( - CommandResultFactory({ - error: new InvalidStatusWordError("V is missing"), - }), - ); - } - }); - - it("should return an error if 'r' is missing", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x90, 0x00]), - data: getSignatureResponse({ omitR: true }), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(InvalidStatusWordError); - expect(result).toStrictEqual( - CommandResultFactory({ - error: new InvalidStatusWordError("R is missing"), - }), - ); - } - }); - - it("should return an error if 's' is missing", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x90, 0x00]), - data: getSignatureResponse({ omitS: true }), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(InvalidStatusWordError); - expect(result).toStrictEqual( - CommandResultFactory({ - error: new InvalidStatusWordError("S is missing"), - }), - ); - } - }); - - it("should return a signature if v, r, and s are present", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x90, 0x00]), - data: getSignatureResponse(), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - - expect(result.status).toBe(CommandResultStatus.Success); - if (isSuccessCommandResult(result)) { - expect(result.data).toEqual({ - v: 27, - r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", - s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", - }); - } - }); - }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts index 0e49c74f9..2e07f1a04 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts @@ -1,41 +1,25 @@ import { - ApduParser, - type ApduResponse, type CommandResult, CommandResultFactory, - GlobalCommandErrorHandler, type InternalApi, InvalidStatusWordError, - isCommandErrorCode, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; import { type Signature } from "@api/model/Signature"; -import { type ClientCommandContext } from "@internal/app-binder/command/client-command-handlers/ClientCommandHandlersTypes"; -import { ContinueCommand } from "@internal/app-binder/command/ContinueCommand"; -import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; -import { - SignMessageCommand, - type SignMessageCommandResponse, -} from "@internal/app-binder/command/SignMessageCommand"; -import { - BitcoinAppCommandError, - bitcoinAppErrors, -} from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { SignMessageCommand } from "@internal/app-binder/command/SignMessageCommand"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { CHUNK_SIZE } from "@internal/app-binder/command/utils/constants"; +import { ContinueTask } from "@internal/app-binder/task/ContinueTask"; import { DataStore } from "@internal/data-store/model/DataStore"; import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; import { DefaultDataStoreService } from "@internal/data-store/service/DefaultDataStoreService"; import { MerkleMapBuilder } from "@internal/merkle-tree/service/MerkleMapBuilder"; import { MerkleTreeBuilder } from "@internal/merkle-tree/service/MerkleTreeBuilder"; import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; -import { CommandUtils } from "@internal/utils/CommandUtils"; -import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; -const R_LENGTH = 32; -const S_LENGTH = 32; - export type SendSignMessageTaskArgs = { derivationPath: string; message: string; @@ -62,7 +46,7 @@ export class SendSignMessageTask { ); } - async run(): Promise> { + async run(): Promise> { const { derivationPath, message } = this.args; const dataStore = new DataStore(); @@ -75,14 +59,6 @@ export class SendSignMessageTask { const merkleRoot = this.dataStoreService.merklizeChunks(dataStore, chunks); - const interpreter = new ClientCommandInterpreter(); - - const commandHandlersContext: ClientCommandContext = { - dataStore, - queue: [], - yieldedResults: [], - }; - const signMessageFirstCommandResponse = await this.api.sendCommand( new SignMessageCommand({ derivationPath, @@ -90,149 +66,15 @@ export class SendSignMessageTask { messageMerkleRoot: merkleRoot, }), ); - if (!isSuccessCommandResult(signMessageFirstCommandResponse)) { - return CommandResultFactory({ - error: new InvalidStatusWordError( - "Invalid signMessageFirstCommandResponse response", - ), - }); - } - - if (this.isSignature(signMessageFirstCommandResponse.data)) { - return CommandResultFactory({ - data: signMessageFirstCommandResponse.data, - }); - } - - let currentResponse = signMessageFirstCommandResponse; - while ( - this.isApduResponse(currentResponse.data) && - CommandUtils.isContinueResponse(currentResponse.data) - ) { - const maybeCommandPayload = interpreter.getClientCommandPayload( - currentResponse.data.data, - commandHandlersContext, - ); - if (maybeCommandPayload.isLeft()) { - return CommandResultFactory({ - error: new InvalidStatusWordError( - maybeCommandPayload.extract().message, - ), - }); - } - - const payload = maybeCommandPayload.extract(); - if (payload instanceof Uint8Array) { - const nextResponse = await this.api.sendCommand( - new ContinueCommand( - { - payload, - }, - this.parseBitcoinSignatureResponse, - ), - ); - if (!isSuccessCommandResult(nextResponse)) { - return CommandResultFactory({ - error: new InvalidStatusWordError("Invalid response type"), - }); - } - if (this.isSignature(nextResponse.data)) { - return CommandResultFactory({ - data: nextResponse.data, - }); - } - - currentResponse = nextResponse; - } - } - - return CommandResultFactory({ - error: new InvalidStatusWordError("Failed to send sign message command."), - }); - } - - private isSignature = ( - response: SignMessageCommandResponse, - ): response is Signature => { - return ( - response && - typeof response === "object" && - "v" in response && - "r" in response && - "s" in response - ); - }; - - private isApduResponse = ( - response: SignMessageCommandResponse, - ): response is ApduResponse => { - return ( - response && - typeof response === "object" && - "statusCode" in response && - "data" in response - ); - }; - - private parseBitcoinSignatureResponse( - response: ApduResponse, - ): CommandResult { - if (BtcCommandUtils.isContinueResponse(response)) { - return CommandResultFactory({ - data: response, - }); - } - - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } - - const parser = new ApduParser(response); - const errorCode = parser.encodeToHexaString(response.statusCode); - if (isCommandErrorCode(errorCode, bitcoinAppErrors)) { - return CommandResultFactory({ - error: new BitcoinAppCommandError({ - ...bitcoinAppErrors[errorCode], - errorCode, - }), - }); - } - - const v = parser.extract8BitUInt(); - if (v === undefined) { - return CommandResultFactory({ - error: new InvalidStatusWordError("V is missing"), - }); - } - - const r = parser.encodeToHexaString( - parser.extractFieldByLength(R_LENGTH), - true, - ); - if (!r) { - return CommandResultFactory({ - error: new InvalidStatusWordError("R is missing"), - }); - } - - const s = parser.encodeToHexaString( - parser.extractFieldByLength(S_LENGTH), - true, + const response = await new ContinueTask(this.api).run( + dataStore, + signMessageFirstCommandResponse, ); - if (!s) { - return CommandResultFactory({ - error: new InvalidStatusWordError("S is missing"), - }); + if (isSuccessCommandResult(response)) { + return BtcCommandUtils.getSignature(response); } - return CommandResultFactory({ - data: { - v, - r, - s, - }, + error: new InvalidStatusWordError("Invalid response from the device"), }); } } diff --git a/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts b/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts index 44caf4bbf..d78720ae8 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts @@ -1,6 +1,6 @@ import { inject, injectable } from "inversify"; -import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionType"; +import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; From 562e05f9dd8e3bce0c8d86da88d29b1946f9c83f Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 27 Dec 2024 18:38:59 +0100 Subject: [PATCH 05/11] :sparkles: (signer-btc): Create SignPsbt DA & task --- .changeset/nervous-points-judge.md | 5 + .../signer/signer-btc/src/api/SignerBtc.ts | 17 +- .../app-binder/SignPsbtDeviceActionTypes.ts | 46 +++ packages/signer/signer-btc/src/api/index.ts | 5 +- .../src/internal/DefaultSignerBtc.ts | 11 +- .../src/internal/app-binder/BtcAppBinder.ts | 22 +- .../GetExtendedPublicKeyCommand.test.ts | 20 +- .../command/SignPsbtCommand.test.ts | 6 +- .../app-binder/command/SignPsbtCommand.ts | 6 + .../command/utils/bitcoinAppError.test.ts | 4 +- .../command/utils/bitcoinAppErrors.ts | 32 +- .../SignPsbt/SignPsbtDeviceAction.test.ts | 369 ++++++++++++++++++ .../SignPsbt/SignPsbtDeviceAction.ts | 216 ++++++++++ .../internal/app-binder/di/appBinderModule.ts | 2 + .../internal/app-binder/di/appBinderTypes.ts | 1 + .../app-binder/task/BuildPsbtTask.test.ts | 100 +++++ .../internal/app-binder/task/BuildPsbtTask.ts | 119 ++++++ .../task/PrepareWalletPolicyTask.test.ts | 186 +++++++++ .../task/PrepareWalletPolicyTask.ts | 76 ++++ .../app-binder/task/SignPsbtTask.test.ts | 205 ++++++++++ .../internal/app-binder/task/SignPsbtTask.ts | 93 +++++ .../internal/use-cases/di/useCasesModule.ts | 2 + .../internal/use-cases/di/useCasesTypes.ts | 1 + .../use-cases/sign-psbt/SignPsbtUseCase.ts | 26 ++ 24 files changed, 1530 insertions(+), 40 deletions(-) create mode 100644 .changeset/nervous-points-judge.md create mode 100644 packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts create mode 100644 packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.ts diff --git a/.changeset/nervous-points-judge.md b/.changeset/nervous-points-judge.md new file mode 100644 index 000000000..b88122666 --- /dev/null +++ b/.changeset/nervous-points-judge.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-bitcoin": minor +--- + +Create SignPsbt API diff --git a/packages/signer/signer-btc/src/api/SignerBtc.ts b/packages/signer/signer-btc/src/api/SignerBtc.ts index 1bc3ee428..c955bcd56 100644 --- a/packages/signer/signer-btc/src/api/SignerBtc.ts +++ b/packages/signer/signer-btc/src/api/SignerBtc.ts @@ -1,23 +1,20 @@ -// import { type AddressOptions } from "@api/model/AddressOptions"; -// import { type Psbt } from "@api/model/Psbt"; -// import { type Signature } from "@api/model/Signature"; -// import { type Wallet } from "@api/model/Wallet"; +import { type GetExtendedPublicKeyDAReturnType } from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; +import { type SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; +import { type SignPsbtDAReturnType } from "@api/app-binder/SignPsbtDeviceActionTypes"; import { type AddressOptions } from "@api/model/AddressOptions"; -import { - type GetExtendedPublicKeyReturnType, - type SignMessageDAReturnType, -} from "@root/src"; +import { type Psbt } from "@api/model/Psbt"; +import { type Wallet } from "@api/model/Wallet"; export interface SignerBtc { getExtendedPublicKey: ( derivationPath: string, options: AddressOptions, - ) => GetExtendedPublicKeyReturnType; + ) => GetExtendedPublicKeyDAReturnType; signMessage: ( derivationPath: string, message: string, ) => SignMessageDAReturnType; + signPsbt: (wallet: Wallet, psbt: Psbt) => SignPsbtDAReturnType; // getAddress: (wallet: Wallet, options?: AddressOptions) => Promise; - // signPsbt: (wallet: Wallet, psbt: Psbt) => Promise; // signTransaction: (wallet: Wallet, psbt: Psbt) => Promise; } diff --git a/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts new file mode 100644 index 000000000..8c928a3ac --- /dev/null +++ b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts @@ -0,0 +1,46 @@ +import { + type CommandErrorResult, + type DeviceActionState, + type ExecuteDeviceActionReturnType, + type OpenAppDAError, + type OpenAppDARequiredInteraction, +} from "@ledgerhq/device-management-kit"; + +import { type Psbt } from "@api/model/Psbt"; +import { type Signature } from "@api/model/Signature"; +import { type Wallet } from "@api/model/Wallet"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; + +export type SignPsbtDAOutput = Signature; + +export type SignPsbtDAInput = { + psbt: Psbt; + wallet: Wallet; +}; + +export type SignPsbtDAError = + | OpenAppDAError + | CommandErrorResult["error"]; + +type SignPsbtDARequiredInteraction = OpenAppDARequiredInteraction; + +export type SignPsbtDAIntermediateValue = { + requiredUserInteraction: SignPsbtDARequiredInteraction; +}; + +export type SignPsbtDAState = DeviceActionState< + SignPsbtDAOutput, + SignPsbtDAError, + SignPsbtDAIntermediateValue +>; + +export type SignPsbtDAInternalState = { + readonly error: SignPsbtDAError | null; + readonly signature: Signature | null; +}; + +export type SignPsbtDAReturnType = ExecuteDeviceActionReturnType< + SignPsbtDAOutput, + SignPsbtDAError, + SignPsbtDAIntermediateValue +>; diff --git a/packages/signer/signer-btc/src/api/index.ts b/packages/signer/signer-btc/src/api/index.ts index 1122cc65e..a1bdfb302 100644 --- a/packages/signer/signer-btc/src/api/index.ts +++ b/packages/signer/signer-btc/src/api/index.ts @@ -7,7 +7,8 @@ export type { SignMessageDAIntermediateValue, SignMessageDAOutput, SignMessageDAState, -} from "@api/app-binder/SignMessageDeviceActionType"; -export * from "@api/app-binder/SignMessageDeviceActionType"; +} from "@api/app-binder/SignMessageDeviceActionTypes"; +export * from "@api/app-binder/SignPsbtDeviceActionTypes"; +export { DefaultDescriptorTemplate, DefaultWallet } from "@api/model/Wallet"; export * from "@api/SignerBtc"; export * from "@api/SignerBtcBuilder"; diff --git a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts index 203ce128b..8d48d8121 100644 --- a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts +++ b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts @@ -4,11 +4,14 @@ import { } from "@ledgerhq/device-management-kit"; import { type Container } from "inversify"; -import { type SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionType"; +import { type SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; import { type AddressOptions } from "@api/model/AddressOptions"; +import { type Psbt } from "@api/model/Psbt"; +import { type Wallet } from "@api/model/Wallet"; 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 SignPsbtUseCase } from "@internal/use-cases/sign-psbt/SignPsbtUseCase"; import { type SignMessageUseCase } from "./use-cases/sign-message/SignMessageUseCase"; import { makeContainer } from "./di"; @@ -25,6 +28,12 @@ export class DefaultSignerBtc implements SignerBtc { this._container = makeContainer({ dmk, sessionId }); } + signPsbt(wallet: Wallet, psbt: Psbt) { + return this._container + .get(useCasesTypes.SignPsbtUseCase) + .execute(wallet, psbt); + } + getExtendedPublicKey( derivationPath: string, { checkOnDevice = false }: AddressOptions, 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 795a1da93..ef70b3fed 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts @@ -8,10 +8,14 @@ import { inject, injectable } from "inversify"; import { GetExtendedPublicKeyDAInput, - GetExtendedPublicKeyReturnType, + GetExtendedPublicKeyDAReturnType, } from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; -import { SignMessageDAReturnType } from "@api/index"; +import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; +import { SignPsbtDAReturnType } from "@api/app-binder/SignPsbtDeviceActionTypes"; +import { Psbt } from "@api/model/Psbt"; +import { Wallet } from "@api/model/Wallet"; import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; +import { SignPsbtDeviceAction } from "@internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction"; import { externalTypes } from "@internal/externalTypes"; import { SignMessageDeviceAction } from "./device-action/SignMessage/SignMessageDeviceAction"; @@ -25,7 +29,7 @@ export class BtcAppBinder { getExtendedPublicKey( args: GetExtendedPublicKeyDAInput, - ): GetExtendedPublicKeyReturnType { + ): GetExtendedPublicKeyDAReturnType { return this.dmk.executeDeviceAction({ sessionId: this.sessionId, deviceAction: new SendCommandInAppDeviceAction({ @@ -54,4 +58,16 @@ export class BtcAppBinder { }), }); } + + signPsbt(args: { psbt: Psbt; wallet: Wallet }): SignPsbtDAReturnType { + return this.dmk.executeDeviceAction({ + sessionId: this.sessionId, + deviceAction: new SignPsbtDeviceAction({ + input: { + psbt: args.psbt, + wallet: args.wallet, + }, + }), + }); + } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts index a497a91f0..709073613 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts @@ -2,10 +2,13 @@ import { ApduResponse, CommandResultFactory, InvalidStatusWordError, - isSuccessCommandResult, - UnknownDeviceExchangeError, } from "@ledgerhq/device-management-kit"; +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; + import { GetExtendedPublicKeyCommand, type GetExtendedPublicKeyCommandArgs, @@ -129,11 +132,14 @@ describe("GetExtendedPublicKeyCommand", () => { const result = command.parseResponse(response); // THEN - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(UnknownDeviceExchangeError); - } else { - fail("Expected an error, but the result was successful"); - } + expect(result).toStrictEqual( + CommandResultFactory({ + error: BtcAppCommandErrorFactory({ + ...BTC_APP_ERRORS["6d00"], + errorCode: "6d00", + }), + }), + ); }); it("should return an error if the response is too short", () => { diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.test.ts index 4299972be..0e2888ebb 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.test.ts @@ -42,9 +42,11 @@ const SIGN_PSBT_APDU = Uint8Array.from([ 0x04, 0x00, 0x01, - 0xc3, + 0xc5, ...GLOBAL_COMMITMENTS, + 0x01, ...INPUTS_COMMITMENTS, + 0x01, ...OUTPUTS_COMMITMENTS, ...WALLET_ID, ...WALLET_HMAC, @@ -53,7 +55,9 @@ const SIGN_PSBT_APDU = Uint8Array.from([ describe("SignPsbtCommand", () => { const args: SignPsbtCommandArgs = { globalCommitments: GLOBAL_COMMITMENTS, + inputsCount: 1, inputsCommitments: INPUTS_COMMITMENTS, + outputsCount: 1, outputsCommitments: OUTPUTS_COMMITMENTS, walletId: WALLET_ID, walletHmac: WALLET_HMAC, diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts index 5484c8895..93f0b5ea6 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts @@ -19,7 +19,9 @@ import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export type SignPsbtCommandArgs = { globalCommitments: Uint8Array; + inputsCount: number; inputsCommitments: Uint8Array; + outputsCount: number; outputsCommitments: Uint8Array; walletId: Uint8Array; walletHmac: Uint8Array; @@ -52,7 +54,9 @@ export class SignPsbtCommand }); const { globalCommitments, + inputsCount, inputsCommitments, + outputsCount, outputsCommitments, walletHmac, walletId, @@ -60,7 +64,9 @@ export class SignPsbtCommand return builder .addBufferToData(globalCommitments) + .add8BitUIntToData(inputsCount) .addBufferToData(inputsCommitments) + .add8BitUIntToData(outputsCount) .addBufferToData(outputsCommitments) .addBufferToData(walletId) .addBufferToData(walletHmac) diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts index 41325cc69..12a882aa2 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts @@ -35,7 +35,7 @@ describe("BtcAppCommandError", () => { }); it("should set the correct customErrorCode", () => { - const errorCode: BtcErrorCodes = "6A86"; + const errorCode: BtcErrorCodes = "6a86"; const error = new BtcAppCommandError({ message: "Either P1 or P2 is incorrect", errorCode, @@ -45,7 +45,7 @@ describe("BtcAppCommandError", () => { }); it("should correlate error codes with messages from bitcoinAppErrors", () => { - const errorCode: BtcErrorCodes = "6E00"; + const errorCode: BtcErrorCodes = "6e00"; const expectedMessage = BTC_APP_ERRORS[errorCode].message; const error = new BtcAppCommandError({ diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts index d7d91bf47..9561ff9d8 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts @@ -5,24 +5,28 @@ import { } from "@ledgerhq/device-management-kit"; export type BtcErrorCodes = + | "6a80" + | "6a82" | "6985" - | "6A86" - | "6A87" - | "6D00" - | "6E00" - | "B000" - | "B007" - | "B008"; + | "6a86" + | "6a87" + | "6d00" + | "6e00" + | "b000" + | "b007" + | "b008"; export const BTC_APP_ERRORS: CommandErrors = { + "6a80": { message: "Incorrect data" }, + "6a82": { message: "Request not supported" }, "6985": { message: "Rejected by user" }, - "6A86": { message: "Either P1 or P2 is incorrect" }, - "6A87": { message: "Lc or minimum APDU length is incorrect" }, - "6D00": { message: "No command exists with the provided INS" }, - "6E00": { message: "Bad CLA used for this application" }, - B000: { message: "Wrong response length (buffer size problem)" }, - B007: { message: "Aborted due to unexpected state reached" }, - B008: { message: "Invalid signature or HMAC" }, + "6a86": { message: "Either P1 or P2 is incorrect" }, + "6a87": { message: "Lc or minimum APDU length is incorrect" }, + "6d00": { message: "No command exists with the provided INS" }, + "6e00": { message: "Bad CLA used for this application" }, + b000: { message: "Wrong response length (buffer size problem)" }, + b007: { message: "Aborted due to unexpected state reached" }, + b008: { message: "Invalid signature or HMAC" }, }; export class BtcAppCommandError extends DeviceExchangeError { diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts new file mode 100644 index 000000000..d199d99a5 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts @@ -0,0 +1,369 @@ +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 SignPsbtDAState } from "@api/app-binder/SignPsbtDeviceActionTypes"; +import { type RegisteredWallet } from "@api/model/Wallet"; +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 { SignPsbtDeviceAction } from "./SignPsbtDeviceAction"; + +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("SignPsbtDeviceAction", () => { + const signPersonalPsbtMock = jest.fn(); + + function extractDependenciesMock() { + return { + signPsbt: signPersonalPsbtMock, + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("Success case", () => { + it("should call external dependencies with the correct parameters", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.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.None, + }, + 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(signPersonalPsbtMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "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 SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if the signPsbt fails", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.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.None, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if the signPsbt throws an exception", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.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.None, + }, + }, + { + status: DeviceActionStatus.Error, + error: new InvalidStatusWordError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if signPsbt return an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.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.None, + }, + }, + { + 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 SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.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.None, + }, + }, + { + 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/SignPsbt/SignPsbtDeviceAction.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts new file mode 100644 index 000000000..39d929c15 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts @@ -0,0 +1,216 @@ +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 SignPsbtDAError, + type SignPsbtDAInput, + type SignPsbtDAIntermediateValue, + type SignPsbtDAInternalState, + type SignPsbtDAOutput, +} from "@api/app-binder/SignPsbtDeviceActionTypes"; +import { type Psbt } from "@api/model/Psbt"; +import { type Signature } from "@api/model/Signature"; +import { type Wallet as ApiWallet } from "@api/model/Wallet"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; + +export type MachineDependencies = { + readonly signPsbt: (arg0: { + input: { wallet: ApiWallet; psbt: Psbt }; + }) => Promise>; +}; + +export type ExtractMachineDependencies = ( + internalApi: InternalApi, +) => MachineDependencies; + +export class SignPsbtDeviceAction extends XStateDeviceAction< + SignPsbtDAOutput, + SignPsbtDAInput, + SignPsbtDAError, + SignPsbtDAIntermediateValue, + SignPsbtDAInternalState +> { + constructor(args: { input: SignPsbtDAInput; inspect?: boolean }) { + super(args); + } + makeStateMachine( + internalApi: InternalApi, + ): DeviceActionStateMachine< + SignPsbtDAOutput, + SignPsbtDAInput, + SignPsbtDAError, + SignPsbtDAIntermediateValue, + SignPsbtDAInternalState + > { + type types = StateMachineTypes< + SignPsbtDAOutput, + SignPsbtDAInput, + SignPsbtDAError, + SignPsbtDAIntermediateValue, + SignPsbtDAInternalState + >; + + const { signPsbt } = 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), + signPsbt: fromPromise(signPsbt), + }, + 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: "SignPsbtDeviceAction", + initial: "OpenAppDeviceAction", + context: ({ input }) => { + return { + input, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + _internalState: { + error: null, + signature: null, + wallet: 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: "SignPsbt", + guard: "noInternalError", + }, + "Error", + ], + }, + SignPsbt: { + invoke: { + id: "signPsbt", + src: "signPsbt", + input: ({ context }) => ({ + psbt: context.input.psbt, + wallet: context.input.wallet, + }), + onDone: { + target: "SignPsbtResultCheck", + 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", + }, + }, + }, + SignPsbtResultCheck: { + 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 signPsbt = async (arg0: { + input: { wallet: ApiWallet; psbt: Psbt }; + }): Promise> => { + return await new SignPsbtTask(internalApi, arg0.input).run(); + }; + return { + signPsbt, + }; + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/di/appBinderModule.ts b/packages/signer/signer-btc/src/internal/app-binder/di/appBinderModule.ts index c2aef75e9..37ef6d120 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/di/appBinderModule.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/di/appBinderModule.ts @@ -2,6 +2,7 @@ import { ContainerModule } from "inversify"; import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; +import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; export const appBinderModuleFactory = () => new ContainerModule( @@ -15,5 +16,6 @@ export const appBinderModuleFactory = () => _onDeactivation, ) => { bind(appBinderTypes.AppBinder).to(BtcAppBinder); + bind(appBinderTypes.SignPsbtTask).to(SignPsbtTask); }, ); diff --git a/packages/signer/signer-btc/src/internal/app-binder/di/appBinderTypes.ts b/packages/signer/signer-btc/src/internal/app-binder/di/appBinderTypes.ts index 732bef938..b45fb7eba 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/di/appBinderTypes.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/di/appBinderTypes.ts @@ -1,3 +1,4 @@ export const appBinderTypes = { AppBinder: Symbol.for("AppBinder"), + SignPsbtTask: Symbol.for("SignPsbtTask"), }; diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.test.ts new file mode 100644 index 000000000..b8657e3ed --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.test.ts @@ -0,0 +1,100 @@ +import { + CommandResultFactory, + isSuccessCommandResult, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; +import { Left, Nothing, Right } from "purify-ts"; + +import { type Psbt } from "@api/model/Psbt"; +import { BuildPsbtTask } from "@internal/app-binder/task/BuildPsbtTask"; +import { type PsbtCommitment } from "@internal/data-store/service/DataStoreService"; +import { type Psbt as InternalPsbt } from "@internal/psbt/model/Psbt"; +import { type Wallet } from "@internal/wallet/model/Wallet"; + +describe("BuildPsbtTask", () => { + it("should build psbt and fill datastore", async () => { + // given + const psbtMapper = { + map: jest.fn(() => + Right({ + getGlobalValue: jest.fn(() => Nothing), + } as unknown as InternalPsbt), + ), + }; + const dataStoreService = { + merklizeWallet: jest.fn(), + merklizePsbt: jest.fn(() => Right({} as PsbtCommitment)), + merklizeChunks: jest.fn(), + }; + const task = new BuildPsbtTask( + { + wallet: {} as unknown as Wallet, + psbt: { + getGlobalValue: jest.fn(), + } as unknown as Psbt, + }, + psbtMapper, + dataStoreService, + ); + // when + const result = await task.run(); + // then + expect(isSuccessCommandResult(result)).toBe(true); + }); + it("should return an error if datastore fails", async () => { + // given + const psbtMapper = { + map: jest.fn(() => Right({} as InternalPsbt)), + }; + const error = new Error("Failed"); + const dataStoreService = { + merklizeWallet: jest.fn(), + merklizePsbt: jest.fn(() => Left(error)), + merklizeChunks: jest.fn(), + }; + const task = new BuildPsbtTask( + { + wallet: {} as unknown as Wallet, + psbt: {} as unknown as Psbt, + }, + psbtMapper, + dataStoreService, + ); + // when + const result = await task.run(); + // then + expect(result).toStrictEqual( + CommandResultFactory({ + error: new UnknownDeviceExchangeError({ error }), + }), + ); + }); + it("should return an error if datastore fails", async () => { + // given + const error = new Error("Failed"); + const psbtMapper = { + map: jest.fn(() => Left(error)), + }; + const dataStoreService = { + merklizeWallet: jest.fn(), + merklizePsbt: jest.fn(() => Right({} as PsbtCommitment)), + merklizeChunks: jest.fn(), + }; + const task = new BuildPsbtTask( + { + wallet: {} as unknown as Wallet, + psbt: {} as unknown as Psbt, + }, + psbtMapper, + dataStoreService, + ); + // when + const result = await task.run(); + // then + expect(result).toStrictEqual( + CommandResultFactory({ + error: new UnknownDeviceExchangeError({ error }), + }), + ); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.ts new file mode 100644 index 000000000..1b97bf821 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.ts @@ -0,0 +1,119 @@ +import { + type CommandResult, + CommandResultFactory, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; +import { EitherAsync } from "purify-ts"; + +import { type Psbt } from "@api/model/Psbt"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { DataStore } from "@internal/data-store/model/DataStore"; +import { + type DataStoreService, + type PsbtCommitment, +} from "@internal/data-store/service/DataStoreService"; +import { DefaultDataStoreService } from "@internal/data-store/service/DefaultDataStoreService"; +import { MerkleMapBuilder } from "@internal/merkle-tree/service/MerkleMapBuilder"; +import { MerkleTreeBuilder } from "@internal/merkle-tree/service/MerkleTreeBuilder"; +import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; +import { + type Psbt as InternalPsbt, + PsbtGlobal, +} from "@internal/psbt/model/Psbt"; +import { DefaultKeySerializer } from "@internal/psbt/service/key/DefaultKeySerializer"; +import { DefaultKeyPairSerializer } from "@internal/psbt/service/key-pair/DefaultKeyPairSerializer"; +import { DefaultPsbtMapper } from "@internal/psbt/service/psbt/DefaultPsbtMapper"; +import { DefaultPsbtSerializer } from "@internal/psbt/service/psbt/DefaultPsbtSerializer"; +import { DefaultPsbtV2Normalizer } from "@internal/psbt/service/psbt/DefaultPsbtV2Normalizer"; +import type { PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import { DefaultValueFactory } from "@internal/psbt/service/value/DefaultValueFactory"; +import { DefaultValueParser } from "@internal/psbt/service/value/DefaultValueParser"; +import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { type Wallet } from "@internal/wallet/model/Wallet"; +import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; + +type BuildPsbtTaskResponse = { + psbtCommitment: PsbtCommitment; + dataStore: DataStore; + inputsCount: number; + outputsCount: number; +}; + +export class BuildPsbtTask { + private readonly _dataStoreService: DataStoreService; + private readonly _psbtMapper: PsbtMapper; + private readonly _valueParser: ValueParser; + + constructor( + private readonly _args: { + wallet: Wallet; + psbt: Psbt; + }, + psbtMapper?: PsbtMapper, + dataStoreService?: DataStoreService, + ) { + this._valueParser = new DefaultValueParser(); + const merkleTreeBuilder = new MerkleTreeBuilder(new Sha256HasherService()); + const merkleMapBuilder = new MerkleMapBuilder(merkleTreeBuilder); + const hasher = new Sha256HasherService(); + + this._psbtMapper = + psbtMapper || + new DefaultPsbtMapper( + new DefaultPsbtSerializer( + this._valueParser, + new DefaultKeyPairSerializer(new DefaultKeySerializer()), + ), + new DefaultPsbtV2Normalizer( + this._valueParser, + new DefaultValueFactory(), + ), + ); + this._dataStoreService = + dataStoreService || + new DefaultDataStoreService( + merkleTreeBuilder, + merkleMapBuilder, + new DefaultWalletSerializer(hasher), + hasher, + ); + } + + async run(): Promise> { + const dataStore = new DataStore(); + let psbt: InternalPsbt; + return await EitherAsync(async ({ liftEither }) => { + // map the input PSBT (V1 or V2, string or byte array) into a normalized and parsed PSBTv2 + psbt = await liftEither(this._psbtMapper.map(this._args.psbt)); + // put wallet policy and PSBT in merkle maps to expose them to the device + this._dataStoreService.merklizeWallet(dataStore, this._args.wallet); + return liftEither(this._dataStoreService.merklizePsbt(dataStore, psbt)); + }).caseOf({ + Left: (error) => { + return CommandResultFactory({ + error: new UnknownDeviceExchangeError({ error }), + }); + }, + Right: (psbtCommitment) => { + return CommandResultFactory({ + data: { + psbtCommitment, + dataStore, + inputsCount: psbt + .getGlobalValue(PsbtGlobal.INPUT_COUNT) + .mapOrDefault( + (value) => this._valueParser.getVarint(value.data).orDefault(0), + 0, + ), + outputsCount: psbt + .getGlobalValue(PsbtGlobal.OUTPUT_COUNT) + .mapOrDefault( + (value) => this._valueParser.getVarint(value.data).orDefault(0), + 0, + ), + }, + }); + }, + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.test.ts new file mode 100644 index 000000000..41649fe1c --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.test.ts @@ -0,0 +1,186 @@ +import { + CommandResultFactory, + type InternalApi, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; + +import { + DefaultDescriptorTemplate, + DefaultWallet, + RegisteredWallet, + type Wallet, +} from "@api/model/Wallet"; +import { PrepareWalletPolicyTask } from "@internal/app-binder/task/PrepareWalletPolicyTask"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; +const fromDefaultWalletMock = jest.fn(); +const fromRegisteredWalletMock = jest.fn(); + +describe("PrepareWalletPolicyTask", () => { + let internalApi: { sendCommand: jest.Mock }; + const walletBuilder = { + fromDefaultWallet: fromDefaultWalletMock, + fromRegisteredWallet: fromRegisteredWalletMock, + } as unknown as WalletBuilder; + beforeEach(() => { + internalApi = { + sendCommand: jest.fn(), + }; + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should return a builded wallet from a default one", async () => { + // given + const defaultWallet = new DefaultWallet( + "49'/0'/0'", + DefaultDescriptorTemplate.LEGACY, + ); + const task = new PrepareWalletPolicyTask( + internalApi as unknown as InternalApi, + { wallet: defaultWallet }, + walletBuilder, + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + extendedPublicKey: "xPublicKey", + }, + }), + ), + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + masterFingerprint: Uint8Array.from([0x42, 0x21, 0x12, 0x24]), + }, + }), + ), + ); + const wallet = {} as Wallet; + fromDefaultWalletMock.mockReturnValue(wallet); + // when + const result = await task.run(); + // then + expect(fromDefaultWalletMock).toHaveBeenCalledWith( + Uint8Array.from([0x42, 0x21, 0x12, 0x24]), + "xPublicKey", + defaultWallet, + ); + expect(result).toStrictEqual( + CommandResultFactory({ + data: wallet, + }), + ); + }); + + it("should return a builded wallet from a registered one", async () => { + // given + const registeredWallet = new RegisteredWallet( + "walletName", + DefaultDescriptorTemplate.LEGACY, + ["key0", "key1"], + Uint8Array.from([42]), + ); + const task = new PrepareWalletPolicyTask( + internalApi as unknown as InternalApi, + { wallet: registeredWallet }, + walletBuilder, + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + extendedPublicKey: "xPublicKey", + }, + }), + ), + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + masterFingerprint: Uint8Array.from([0x42, 0x21, 0x12, 0x24]), + }, + }), + ), + ); + const wallet = {} as Wallet; + fromRegisteredWalletMock.mockReturnValue(wallet); + // when + const result = await task.run(); + // then + expect(fromRegisteredWalletMock).toHaveBeenCalledWith(registeredWallet); + expect(result).toStrictEqual( + CommandResultFactory({ + data: wallet, + }), + ); + }); + + it("should return an error if getMasterFingerprint failed", async () => { + // given + const defaultWallet = new DefaultWallet( + "49'/0'/0'", + DefaultDescriptorTemplate.LEGACY, + ); + const task = new PrepareWalletPolicyTask( + internalApi as unknown as InternalApi, + { wallet: defaultWallet }, + walletBuilder, + ); + const error = new UnknownDeviceExchangeError("Failed"); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + error, + }), + ), + ); + // when + const result = await task.run(); + // then + expect(result).toStrictEqual(CommandResultFactory({ error })); + }); + + it("should return an error if getExtendedPublicKey failed", async () => { + // given + const defaultWallet = new DefaultWallet( + "49'/0'/0'", + DefaultDescriptorTemplate.LEGACY, + ); + const task = new PrepareWalletPolicyTask( + internalApi as unknown as InternalApi, + { wallet: defaultWallet }, + walletBuilder, + ); + const error = new UnknownDeviceExchangeError("Failed"); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + masterFingerprint: Uint8Array.from([0x42, 0x21, 0x12, 0x24]), + }, + }), + ), + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + error, + }), + ), + ); + // when + const result = await task.run(); + // then + expect(result).toStrictEqual(CommandResultFactory({ error })); + expect(result).toStrictEqual( + CommandResultFactory({ + error, + }), + ); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.ts new file mode 100644 index 000000000..898f7641c --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.ts @@ -0,0 +1,76 @@ +import { + CommandResultFactory, + type InternalApi, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; + +import { + type DefaultWallet, + type Wallet as ApiWallet, +} from "@api/model/Wallet"; +import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; +import { GetMasterFingerprintCommand } from "@internal/app-binder/command/GetMasterFingerprintCommand"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { MerkleTreeBuilder } from "@internal/merkle-tree/service/MerkleTreeBuilder"; +import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; +import { type Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; +import { DefaultWalletBuilder } from "@internal/wallet/service/DefaultWalletBuilder"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; + +export type PrepareWalletPolicyTaskArgs = { wallet: ApiWallet }; + +export class PrepareWalletPolicyTask { + private readonly _walletBuilder: WalletBuilder; + constructor( + private readonly _api: InternalApi, + private readonly _args: PrepareWalletPolicyTaskArgs, + walletBuilder?: WalletBuilder, + ) { + this._walletBuilder = + walletBuilder || + new DefaultWalletBuilder( + new MerkleTreeBuilder(new Sha256HasherService()), + ); + } + + private isDefaultWallet(wallet: ApiWallet): wallet is DefaultWallet { + return "derivationPath" in wallet; + } + + async run() { + const { wallet } = this._args; + + // Return build from a registered wallet + if (!this.isDefaultWallet(wallet)) { + return Promise.resolve( + CommandResultFactory({ + data: this._walletBuilder.fromRegisteredWallet(wallet), + }), + ); + } + // Get xpub and masterfingerprint for a default wallet + const xPubKeyResult = await this._api.sendCommand( + new GetExtendedPublicKeyCommand({ + checkOnDevice: false, + derivationPath: wallet.derivationPath, + }), + ); + if (!isSuccessCommandResult(xPubKeyResult)) { + return xPubKeyResult; + } + const masterFingerprintResult = await this._api.sendCommand( + new GetMasterFingerprintCommand(), + ); + if (!isSuccessCommandResult(masterFingerprintResult)) { + return masterFingerprintResult; + } + // Return build from a default wallet + return CommandResultFactory({ + data: this._walletBuilder.fromDefaultWallet( + masterFingerprintResult.data.masterFingerprint, + xPubKeyResult.data.extendedPublicKey, + wallet, + ), + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts new file mode 100644 index 000000000..aea873220 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts @@ -0,0 +1,205 @@ +import { + ApduResponse, + CommandResultFactory, + type InternalApi, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; + +import { type DefaultWallet } from "@api/model/Wallet"; +import { SignPsbtCommand } from "@internal/app-binder/command/SignPsbtCommand"; +import { BuildPsbtTask } from "@internal/app-binder/task/BuildPsbtTask"; +import { ContinueTask } from "@internal/app-binder/task/ContinueTask"; +import { PrepareWalletPolicyTask } from "@internal/app-binder/task/PrepareWalletPolicyTask"; +import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; +import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +const mockRunBuildPsbt = jest.fn(); +const mockRunPrepareWallet = jest.fn(); +const mockRunContinue = jest.fn(() => + CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }), + }), +); + +jest.mock("@internal/app-binder/task/BuildPsbtTask", () => ({ + BuildPsbtTask: jest.fn().mockImplementation(() => ({ + run: mockRunBuildPsbt, + })), +})); +jest.mock("@internal/app-binder/task/ContinueTask", () => ({ + ContinueTask: jest.fn().mockImplementation(() => ({ + run: mockRunContinue, + })), +})); +jest.mock("@internal/app-binder/task/PrepareWalletPolicyTask", () => ({ + PrepareWalletPolicyTask: jest.fn().mockImplementation(() => ({ + run: mockRunPrepareWallet, + })), +})); + +describe("SignPsbtTask", () => { + describe("run", () => { + it("should call all tasks", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = ""; + const wallet = {} as DefaultWallet; + const walletSerializer = { + getId: jest.fn(() => Uint8Array.from([0x05])), + } as unknown as WalletSerializer; + mockRunBuildPsbt.mockReturnValue( + CommandResultFactory({ + data: { + psbtCommitment: { + inputsRoot: Uint8Array.from([0x01]), + outputsRoot: Uint8Array.from([0x02]), + globalCommitment: Uint8Array.from([0x03]), + }, + inputsCount: 42, + outputsCount: 42, + }, + }), + ); + mockRunPrepareWallet.mockReturnValue( + CommandResultFactory({ + data: { + hmac: Uint8Array.from([0x04]), + }, + }), + ); + + // when + await new SignPsbtTask( + api, + { + psbt, + wallet, + }, + walletSerializer, + ).run(); + // then + expect(BuildPsbtTask).toHaveBeenCalled(); + expect(ContinueTask).toHaveBeenCalled(); + expect(PrepareWalletPolicyTask).toHaveBeenCalled(); + expect(api.sendCommand).toHaveBeenCalledWith( + new SignPsbtCommand({ + globalCommitments: Uint8Array.from([0x03]), + inputsCount: 42, + inputsCommitments: Uint8Array.from([0x01]), + outputsCount: 42, + outputsCommitments: Uint8Array.from([0x02]), + walletId: Uint8Array.from([0x05]), + walletHmac: Uint8Array.from([0x04]), + }), + ); + }); + }); + describe("errors", () => { + it("should return an error if build psbt fails", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = ""; + const wallet = {} as DefaultWallet; + mockRunPrepareWallet.mockReturnValue( + CommandResultFactory({ + data: {}, + }), + ); + mockRunBuildPsbt.mockReturnValue( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Failed"), + }), + ); + // when + const result = await new SignPsbtTask(api, { + psbt, + wallet, + }).run(); + // then + expect(result).toStrictEqual( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Failed"), + }), + ); + }); + it("should return an error if prepare wallet fails", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = ""; + const wallet = {} as DefaultWallet; + mockRunBuildPsbt.mockReturnValue( + CommandResultFactory({ + data: {}, + }), + ); + mockRunPrepareWallet.mockReturnValue( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Failed"), + }), + ); + // when + const result = await new SignPsbtTask(api, { + psbt, + wallet, + }).run(); + // then + expect(result).toStrictEqual( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Failed"), + }), + ); + }); + it("should return an error if continue task fails", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = ""; + const wallet = {} as DefaultWallet; + const walletSerializer = { + getId: jest.fn(() => Uint8Array.from([0x05])), + } as unknown as WalletSerializer; + mockRunContinue.mockReturnValue( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Failed"), + }), + ); + mockRunPrepareWallet.mockReturnValue( + CommandResultFactory({ + data: {}, + }), + ); + mockRunBuildPsbt.mockReturnValue( + CommandResultFactory({ + data: { + psbtCommitment: {}, + }, + }), + ); + // when + const result = await new SignPsbtTask( + api, + { + psbt, + wallet, + }, + walletSerializer, + ).run(); + // then + expect(result).toStrictEqual( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Failed"), + }), + ); + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts new file mode 100644 index 000000000..17c75bc6a --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts @@ -0,0 +1,93 @@ +import { + type InternalApi, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; +import { injectable } from "inversify"; + +import { Psbt } from "@api/model/Psbt"; +import { Wallet as ApiWallet } from "@api/model/Wallet"; +import { SignPsbtCommand } from "@internal/app-binder/command/SignPsbtCommand"; +import { BuildPsbtTask } from "@internal/app-binder/task/BuildPsbtTask"; +import { ContinueTask } from "@internal/app-binder/task/ContinueTask"; +import { PrepareWalletPolicyTask } from "@internal/app-binder/task/PrepareWalletPolicyTask"; +import { DataStore } from "@internal/data-store/model/DataStore"; +import { PsbtCommitment } from "@internal/data-store/service/DataStoreService"; +import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; +import { Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; +import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; +import type { WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +export type SignPsbtTaskArgs = { + psbt: Psbt; + wallet: ApiWallet; +}; + +@injectable() +export class SignPsbtTask { + private readonly _walletSerializer: WalletSerializer; + constructor( + private readonly _api: InternalApi, + private readonly _args: SignPsbtTaskArgs, + walletSerializer?: WalletSerializer, + ) { + const hasher = new Sha256HasherService(); + this._walletSerializer = + walletSerializer || new DefaultWalletSerializer(hasher); + } + + private async runPrepareWalletPolicy() { + return new PrepareWalletPolicyTask(this._api, { + wallet: this._args.wallet, + }).run(); + } + private async runBuildPsbt(wallet: InternalWallet) { + return new BuildPsbtTask({ wallet, psbt: this._args.psbt }).run(); + } + + private async runSignPsbt( + psbtCommitment: PsbtCommitment, + dataStore: DataStore, + inputsCount: number, + outputsCount: number, + wallet: InternalWallet, + ) { + const signPsbtCommandResult = await this._api.sendCommand( + new SignPsbtCommand({ + globalCommitments: psbtCommitment.globalCommitment, + inputsCount, + inputsCommitments: psbtCommitment.inputsRoot, + outputsCount, + outputsCommitments: psbtCommitment.outputsRoot, + walletId: this._walletSerializer.getId(wallet), + walletHmac: wallet.hmac, + }), + ); + + const continueTask = new ContinueTask(this._api); + const result = await continueTask.run(dataStore, signPsbtCommandResult); + + if (isSuccessCommandResult(result)) { + return BtcCommandUtils.getSignature(result); + } + return result; + } + + async run() { + const walletResult = await this.runPrepareWalletPolicy(); + if (!isSuccessCommandResult(walletResult)) { + return walletResult; + } + const psbtResult = await this.runBuildPsbt(walletResult.data); + if (!isSuccessCommandResult(psbtResult)) { + return psbtResult; + } + return await this.runSignPsbt( + psbtResult.data.psbtCommitment, + psbtResult.data.dataStore, + psbtResult.data.inputsCount, + psbtResult.data.outputsCount, + walletResult.data, + ); + } +} diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts index 666c669b0..b66e24168 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts @@ -3,6 +3,7 @@ import { ContainerModule } from "inversify"; import { useCasesTypes } from "@internal/use-cases/di/useCasesTypes"; import { GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase"; import { SignMessageUseCase } from "@internal/use-cases/sign-message/SignMessageUseCase"; +import { SignPsbtUseCase } from "@internal/use-cases/sign-psbt/SignPsbtUseCase"; export const useCasesModuleFactory = () => new ContainerModule( @@ -19,5 +20,6 @@ export const useCasesModuleFactory = () => GetExtendedPublicKeyUseCase, ); bind(useCasesTypes.SignMessageUseCase).to(SignMessageUseCase); + bind(useCasesTypes.SignPsbtUseCase).to(SignPsbtUseCase); }, ); diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts index 2b00e636e..e2ebd8142 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts @@ -1,4 +1,5 @@ export const useCasesTypes = { GetExtendedPublicKeyUseCase: Symbol.for("GetExtendedPublicKeyUseCase"), SignMessageUseCase: Symbol.for("SignMessageUseCase"), + SignPsbtUseCase: Symbol.for("SignPsbtUseCase"), }; diff --git a/packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.ts b/packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.ts new file mode 100644 index 000000000..b95998051 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.ts @@ -0,0 +1,26 @@ +import { inject, injectable } from "inversify"; + +import { SignPsbtDAReturnType } from "@api/app-binder/SignPsbtDeviceActionTypes"; +import { Psbt } from "@api/model/Psbt"; +import { Wallet } from "@api/model/Wallet"; +import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; +import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; + +@injectable() +export class SignPsbtUseCase { + private _appBinder: BtcAppBinder; + + constructor( + @inject(appBinderTypes.AppBinder) + appBinding: BtcAppBinder, + ) { + this._appBinder = appBinding; + } + + execute(wallet: Wallet, psbt: Psbt): SignPsbtDAReturnType { + return this._appBinder.signPsbt({ + wallet, + psbt, + }); + } +} From 42114ec6ac6073781167650ad29cc683c046936f Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 27 Dec 2024 18:39:29 +0100 Subject: [PATCH 06/11] :lipstick: (smpl): SignPsbt view --- apps/sample/package.json | 2 +- .../src/components/SignerBtcView/index.tsx | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/sample/package.json b/apps/sample/package.json index d297405ad..f9b3eb9c3 100644 --- a/apps/sample/package.json +++ b/apps/sample/package.json @@ -19,9 +19,9 @@ "@ledgerhq/device-management-kit": "workspace:*", "@ledgerhq/device-management-kit-flipper-plugin-client": "workspace:*", "@ledgerhq/device-mockserver-client": "workspace:*", + "@ledgerhq/device-signer-kit-bitcoin": "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/components/SignerBtcView/index.tsx b/apps/sample/src/components/SignerBtcView/index.tsx index 85227d8cb..1a2951e29 100644 --- a/apps/sample/src/components/SignerBtcView/index.tsx +++ b/apps/sample/src/components/SignerBtcView/index.tsx @@ -1,5 +1,7 @@ import React, { useMemo } from "react"; import { + DefaultDescriptorTemplate, + DefaultWallet, type GetExtendedDAIntermediateValue, type GetExtendedPublicKeyDAError, type GetExtendedPublicKeyDAOutput, @@ -7,12 +9,16 @@ import { type SignMessageDAError, type SignMessageDAIntermediateValue, type SignMessageDAOutput, + type SignPsbtDAError, + type SignPsbtDAIntermediateValue, + type SignPsbtDAOutput, } from "@ledgerhq/device-signer-kit-bitcoin"; import { DeviceActionsList } from "@/components/DeviceActionsView/DeviceActionsList"; import { type DeviceActionProps } from "@/components/DeviceActionsView/DeviceActionTester"; import { useDmk } from "@/providers/DeviceManagementKitProvider"; +// Native segwit const DEFAULT_DERIVATION_PATH = "84'/0'/0'"; export const SignerBtcView: React.FC<{ sessionId: string }> = ({ @@ -78,6 +84,37 @@ export const SignerBtcView: React.FC<{ sessionId: string }> = ({ SignMessageDAError, SignMessageDAIntermediateValue >, + { + title: "Sign psbt", + description: + "Perform all the actions necessary to sign a PSBT with the device", + executeDeviceAction: ({ derivationPath, psbt }) => { + if (!signer) { + throw new Error("Signer not initialized"); + } + + return signer.signPsbt( + new DefaultWallet( + derivationPath, + DefaultDescriptorTemplate.TAPROOT, + ), + psbt, + ); + }, + initialValues: { + derivationPath: DEFAULT_DERIVATION_PATH, + psbt: "cHNidP8BAFUCAAAAAVEiws3mgj5VdUF1uSycV6Co4ayDw44Xh/06H/M0jpUTAQAAAAD9////AXhBDwAAAAAAGXapFBPX1YFmlGw+wCKTQGbYwNER0btBiKwaBB0AAAEA+QIAAAAAAQHsIw5TCVJWBSokKCcO7ASYlEsQ9vHFePQxwj0AmLSuWgEAAAAXFgAUKBU5gg4t6XOuQbpgBLQxySHE2G3+////AnJydQAAAAAAF6kUyLkGrymMcOYDoow+/C+uGearKA+HQEIPAAAAAAAZdqkUy65bUM+Tnm9TG4prer14j+FLApeIrAJHMEQCIDfstCSDYar9T4wR5wXw+npfvc1ZUXL81WQ/OxG+/11AAiACDG0yb2w31jzsra9OszX67ffETgX17x0raBQLAjvRPQEhA9rIL8Cs/Pw2NI1KSKRvAc6nfyuezj+MO0yZ0LCy+ZXShPIcACIGAu6GCCB+IQKEJvaedkR9fj1eB3BJ9eaDwxNsIxR2KkcYGPWswv0sAACAAQAAgAAAAIAAAAAAAAAAAAAA", + }, + deviceModelId, + } satisfies DeviceActionProps< + SignPsbtDAOutput, + { + psbt: string; + derivationPath: string; + }, + SignPsbtDAError, + SignPsbtDAIntermediateValue + >, ], [deviceModelId, signer], ); From 5ee3e96b0cab9e21e03a509babc851fe60b4dd62 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Thu, 2 Jan 2025 12:09:37 +0100 Subject: [PATCH 07/11] :bug: (dmk): Fix CommandUtils static calls --- .changeset/blue-lemons-impress.md | 5 +++++ .../src/api/command/utils/CommandUtils.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/blue-lemons-impress.md diff --git a/.changeset/blue-lemons-impress.md b/.changeset/blue-lemons-impress.md new file mode 100644 index 000000000..219dfe3fd --- /dev/null +++ b/.changeset/blue-lemons-impress.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit": patch +--- + +Fix CommandUtils static calls diff --git a/packages/device-management-kit/src/api/command/utils/CommandUtils.ts b/packages/device-management-kit/src/api/command/utils/CommandUtils.ts index 3b51440a4..1bab7acbf 100644 --- a/packages/device-management-kit/src/api/command/utils/CommandUtils.ts +++ b/packages/device-management-kit/src/api/command/utils/CommandUtils.ts @@ -6,7 +6,7 @@ export class CommandUtils { } static isSuccessResponse({ statusCode }: ApduResponse) { - if (!this.isValidStatusCode(statusCode)) { + if (!CommandUtils.isValidStatusCode(statusCode)) { return false; } @@ -14,7 +14,7 @@ export class CommandUtils { } static isLockedDeviceResponse({ statusCode }: ApduResponse) { - if (!this.isValidStatusCode(statusCode)) { + if (!CommandUtils.isValidStatusCode(statusCode)) { return false; } From cee7864422c92cfdb103c5f2ab915dcf819a742f Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Mon, 6 Jan 2025 11:05:51 +0100 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=90=9B=20(signer-btc):=20Fix=20wall?= =?UTF-8?q?et=20&=20pub=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sample/src/components/SignerBtcView/index.tsx | 5 ++--- .../app-binder/command/GetExtendedPublicKeyCommand.test.ts | 4 ++-- .../app-binder/command/GetExtendedPublicKeyCommand.ts | 4 +--- .../src/internal/wallet/service/DefaultWalletBuilder.test.ts | 2 +- .../src/internal/wallet/service/DefaultWalletBuilder.ts | 2 +- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/sample/src/components/SignerBtcView/index.tsx b/apps/sample/src/components/SignerBtcView/index.tsx index 1a2951e29..a3746963b 100644 --- a/apps/sample/src/components/SignerBtcView/index.tsx +++ b/apps/sample/src/components/SignerBtcView/index.tsx @@ -18,7 +18,6 @@ import { DeviceActionsList } from "@/components/DeviceActionsView/DeviceActionsL import { type DeviceActionProps } from "@/components/DeviceActionsView/DeviceActionTester"; import { useDmk } from "@/providers/DeviceManagementKitProvider"; -// Native segwit const DEFAULT_DERIVATION_PATH = "84'/0'/0'"; export const SignerBtcView: React.FC<{ sessionId: string }> = ({ @@ -96,14 +95,14 @@ export const SignerBtcView: React.FC<{ sessionId: string }> = ({ return signer.signPsbt( new DefaultWallet( derivationPath, - DefaultDescriptorTemplate.TAPROOT, + DefaultDescriptorTemplate.NATIVE_SEGWIT, ), psbt, ); }, initialValues: { derivationPath: DEFAULT_DERIVATION_PATH, - psbt: "cHNidP8BAFUCAAAAAVEiws3mgj5VdUF1uSycV6Co4ayDw44Xh/06H/M0jpUTAQAAAAD9////AXhBDwAAAAAAGXapFBPX1YFmlGw+wCKTQGbYwNER0btBiKwaBB0AAAEA+QIAAAAAAQHsIw5TCVJWBSokKCcO7ASYlEsQ9vHFePQxwj0AmLSuWgEAAAAXFgAUKBU5gg4t6XOuQbpgBLQxySHE2G3+////AnJydQAAAAAAF6kUyLkGrymMcOYDoow+/C+uGearKA+HQEIPAAAAAAAZdqkUy65bUM+Tnm9TG4prer14j+FLApeIrAJHMEQCIDfstCSDYar9T4wR5wXw+npfvc1ZUXL81WQ/OxG+/11AAiACDG0yb2w31jzsra9OszX67ffETgX17x0raBQLAjvRPQEhA9rIL8Cs/Pw2NI1KSKRvAc6nfyuezj+MO0yZ0LCy+ZXShPIcACIGAu6GCCB+IQKEJvaedkR9fj1eB3BJ9eaDwxNsIxR2KkcYGPWswv0sAACAAQAAgAAAAIAAAAAAAAAAAAAA", + psbt: "70736274ff0104010101fb0402000000010204020000000105010100011004000000000103040100000001007102000000013daeeb9a92e7b5af90c787d53f0e60d2cf4cfd47bca9a0d8bc77a7464b024c0b00000000000000000002ff0300000000000016001402fe597c6ec0e2982712929bcf079a4e11d37e8d950b0000000000001600144dc432cb6a26c52a1e6ddd2bcf0ee49199fae0cc000000002206031869567d5e88d988ff7baf6827983f89530ddd79dbaeadaa6ec538a8f03dea8b18f5acc2fd540000800000008000000080000000000000000001011fff0300000000000016001402fe597c6ec0e2982712929bcf079a4e11d37e8d010e200cf08d04fa11ff024d5a50165ba65e495409b50ba6657788dfa15274adb682df010f0400000000000103086b01000000000000010416001429159115f12bb6a7e977439c83d3f8d555d72d5f00", }, deviceModelId, } satisfies DeviceActionProps< diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts index 709073613..44bfda957 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts @@ -38,7 +38,7 @@ const GET_EXTENDED_PUBLIC_KEY_VALID_RESPONSE = new Uint8Array([ 0x59, 0x6d, 0x6b, 0x53, 0x48, 0x4c, 0x66, 0x52, 0x31, 0x56, 0x51, 0x59, 0x6a, 0x35, 0x6a, 0x61, 0x79, 0x71, 0x77, 0x53, 0x59, 0x41, 0x52, 0x6e, 0x75, 0x42, 0x4a, 0x69, 0x50, 0x53, 0x44, 0x61, 0x62, 0x79, 0x79, 0x54, 0x69, 0x43, 0x44, - 0x37, 0x42, 0x33, 0x63, 0x6a, 0x50, 0x71, 0x90, 0x00, + 0x37, 0x42, 0x33, 0x63, 0x6a, 0x50, 0x71, ]); describe("GetExtendedPublicKeyCommand", () => { @@ -146,7 +146,7 @@ describe("GetExtendedPublicKeyCommand", () => { // GIVEN command = new GetExtendedPublicKeyCommand(defaultArgs); const response = new ApduResponse({ - data: GET_EXTENDED_PUBLIC_KEY_VALID_RESPONSE.slice(0, 2), + data: Uint8Array.from([]), statusCode: new Uint8Array([0x90, 0x00]), }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts index b15f0461f..a1ac455bc 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts @@ -23,8 +23,6 @@ import { } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; -const STATUS_CODE_LENGTH = 2; - export type GetExtendedPublicKeyCommandArgs = { checkOnDevice: boolean; derivationPath: string; @@ -83,7 +81,7 @@ export class GetExtendedPublicKeyCommand this._errorHelper.getError(response), ).orDefaultLazy(() => { const parser = new ApduParser(response); - const length = parser.getUnparsedRemainingLength() - STATUS_CODE_LENGTH; + const length = parser.getUnparsedRemainingLength(); if (length <= 0) { return CommandResultFactory({ diff --git a/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.test.ts b/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.test.ts index 67db87533..f3a9bf987 100644 --- a/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.test.ts +++ b/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.test.ts @@ -57,7 +57,7 @@ describe("DefaultWalletBuilder tests", () => { // Given const builder = new DefaultWalletBuilder(mockMerkleTree); const defaultWallet = new DefaultWallet( - "/48'/1'/0'/0'", + "48'/1'/0'/0'", DefaultDescriptorTemplate.NATIVE_SEGWIT, ); const masterFingerprint = hexaStringToBuffer("5c9e228d")!; diff --git a/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.ts b/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.ts index 2c587c850..004c759d6 100644 --- a/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.ts +++ b/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.ts @@ -39,7 +39,7 @@ export class DefaultWalletBuilder implements WalletBuilder { // For internal keys, the xpub should be put after key origin informations // https://github.com/LedgerHQ/app-bitcoin-new/blob/master/doc/wallet.md#keys-information-vector const hexMasterFingerprint = bufferToHexaString(masterFingerprint).slice(2); - const keyOrigin = `[${hexMasterFingerprint}${wallet.derivationPath}]`; + const keyOrigin = `[${hexMasterFingerprint}/${wallet.derivationPath}]`; const key = `${keyOrigin}${extendedPublicKey}`; // Empty name for default wallets const name = ""; From 0663f38e20ec03e76beb0965c4df2e7ded9cab2b Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Wed, 8 Jan 2025 16:26:15 +0100 Subject: [PATCH 09/11] :sparkles: (signer-btc): Get PSBT signature --- .../app-binder/SignPsbtDeviceActionTypes.ts | 7 +++--- .../SignPsbt/SignPsbtDeviceAction.test.ts | 12 ++-------- .../SignPsbt/SignPsbtDeviceAction.ts | 5 ++-- .../app-binder/task/ContinueTask.test.ts | 9 ++++--- .../internal/app-binder/task/ContinueTask.ts | 24 ++++++++++--------- .../app-binder/task/SignMessageTask.ts | 3 +-- .../app-binder/task/SignPsbtTask.test.ts | 1 + .../internal/app-binder/task/SignPsbtTask.ts | 13 ++++++---- 8 files changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts index 8c928a3ac..2d7bdf573 100644 --- a/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts +++ b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts @@ -7,11 +7,11 @@ import { } from "@ledgerhq/device-management-kit"; import { type Psbt } from "@api/model/Psbt"; -import { type Signature } from "@api/model/Signature"; import { type Wallet } from "@api/model/Wallet"; import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; -export type SignPsbtDAOutput = Signature; +// @toDo Update this return value to Psbt once it would be updated in SignPsbtTask +export type SignPsbtDAOutput = Uint8Array[]; export type SignPsbtDAInput = { psbt: Psbt; @@ -36,7 +36,8 @@ export type SignPsbtDAState = DeviceActionState< export type SignPsbtDAInternalState = { readonly error: SignPsbtDAError | null; - readonly signature: Signature | null; + // [SHOULD] be psbt instead of signature + readonly signature: Uint8Array[] | null; }; export type SignPsbtDAReturnType = ExecuteDeviceActionReturnType< diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts index d199d99a5..3e97da352 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts @@ -56,11 +56,7 @@ describe("SignPsbtDeviceAction", () => { .mockReturnValue(extractDependenciesMock()); signPersonalPsbtMock.mockResolvedValueOnce( CommandResultFactory({ - data: { - v: 0x1c, - r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788", - s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513", - }, + data: [Uint8Array.from([0x01, 0x02, 0x03])], }), ); @@ -86,11 +82,7 @@ describe("SignPsbtDeviceAction", () => { status: DeviceActionStatus.Pending, }, { - output: { - v: 0x1c, - r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788", - s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513", - }, + output: [Uint8Array.from([0x01, 0x02, 0x03])], status: DeviceActionStatus.Completed, }, ]; diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts index 39d929c15..4f32cc61a 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts @@ -20,7 +20,6 @@ import { type SignPsbtDAOutput, } from "@api/app-binder/SignPsbtDeviceActionTypes"; import { type Psbt } from "@api/model/Psbt"; -import { type Signature } from "@api/model/Signature"; import { type Wallet as ApiWallet } from "@api/model/Wallet"; import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; @@ -28,7 +27,7 @@ import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; export type MachineDependencies = { readonly signPsbt: (arg0: { input: { wallet: ApiWallet; psbt: Psbt }; - }) => Promise>; + }) => Promise>; }; export type ExtractMachineDependencies = ( @@ -206,7 +205,7 @@ export class SignPsbtDeviceAction extends XStateDeviceAction< extractDependencies(internalApi: InternalApi): MachineDependencies { const signPsbt = async (arg0: { input: { wallet: ApiWallet; psbt: Psbt }; - }): Promise> => { + }): Promise> => { return await new SignPsbtTask(internalApi, arg0.input).run(); }; return { diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts index c382515f4..9e9f70a5a 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts @@ -55,9 +55,10 @@ describe("ContinueTask", () => { // when const task = new ContinueTask( api as unknown as InternalApi, + {} as DataStore, clientCommandInterpreter, ); - await task.run({} as DataStore, fromResult); + await task.run(fromResult); // then expect( clientCommandInterpreter.getClientCommandPayload, @@ -79,9 +80,10 @@ describe("ContinueTask", () => { // when const task = new ContinueTask( api as unknown as InternalApi, + {} as DataStore, clientCommandInterpreter, ); - const result = await task.run({} as DataStore, fromResult); + const result = await task.run(fromResult); // then expect(api.sendCommand).toHaveBeenCalledTimes(0); expect(result).toStrictEqual( @@ -101,9 +103,10 @@ describe("ContinueTask", () => { // when const task = new ContinueTask( api as unknown as InternalApi, + {} as DataStore, clientCommandInterpreter, ); - const result = await task.run({} as DataStore, fromResult); + const result = await task.run(fromResult); // then expect( clientCommandInterpreter.getClientCommandPayload, diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts index f5034af9c..a155a86e6 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts @@ -20,36 +20,33 @@ import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export class ContinueTask { private readonly _clientCommandInterpreter: ClientCommandInterpreter; + private readonly _context: ClientCommandContext; constructor( private readonly _api: InternalApi, + dataStore: DataStore, clientCommandInterpreter?: ClientCommandInterpreter, ) { + this._context = { + dataStore, + queue: [], + yieldedResults: [], + }; this._clientCommandInterpreter = clientCommandInterpreter || new ClientCommandInterpreter(); } async run( - dataStore: DataStore, fromResult: CommandResult, ): Promise> { let currentResponse: CommandResult = fromResult; - const commandHandlersContext: ClientCommandContext = { - dataStore, - queue: [], - yieldedResults: [], - }; - while ( this.isApduResult(currentResponse) && BtcCommandUtils.isContinueResponse(currentResponse.data) ) { currentResponse = await this._clientCommandInterpreter - .getClientCommandPayload( - currentResponse.data.data, - commandHandlersContext, - ) + .getClientCommandPayload(currentResponse.data.data, this._context) .caseOf({ Left: (error) => Promise.resolve( @@ -67,6 +64,11 @@ export class ContinueTask { } return currentResponse; } + + getYieldedResults() { + return this._context.yieldedResults; + } + private isApduResult = ( response: CommandResult, ): response is CommandSuccessResult => { diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts index 2e07f1a04..8199db8fe 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts @@ -66,8 +66,7 @@ export class SendSignMessageTask { messageMerkleRoot: merkleRoot, }), ); - const response = await new ContinueTask(this.api).run( - dataStore, + const response = await new ContinueTask(this.api, dataStore).run( signMessageFirstCommandResponse, ); if (isSuccessCommandResult(response)) { diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts index aea873220..35f23b895 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts @@ -32,6 +32,7 @@ jest.mock("@internal/app-binder/task/BuildPsbtTask", () => ({ jest.mock("@internal/app-binder/task/ContinueTask", () => ({ ContinueTask: jest.fn().mockImplementation(() => ({ run: mockRunContinue, + getYieldedResults: jest.fn(() => []), })), })); jest.mock("@internal/app-binder/task/PrepareWalletPolicyTask", () => ({ diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts index 17c75bc6a..7e652eeee 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts @@ -1,4 +1,6 @@ import { + CommandResult, + CommandResultFactory, type InternalApi, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; @@ -7,13 +9,13 @@ import { injectable } from "inversify"; import { Psbt } from "@api/model/Psbt"; import { Wallet as ApiWallet } from "@api/model/Wallet"; import { SignPsbtCommand } from "@internal/app-binder/command/SignPsbtCommand"; +import { BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { BuildPsbtTask } from "@internal/app-binder/task/BuildPsbtTask"; import { ContinueTask } from "@internal/app-binder/task/ContinueTask"; import { PrepareWalletPolicyTask } from "@internal/app-binder/task/PrepareWalletPolicyTask"; import { DataStore } from "@internal/data-store/model/DataStore"; import { PsbtCommitment } from "@internal/data-store/service/DataStoreService"; import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; -import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; import { Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; import type { WalletSerializer } from "@internal/wallet/service/WalletSerializer"; @@ -51,7 +53,7 @@ export class SignPsbtTask { inputsCount: number, outputsCount: number, wallet: InternalWallet, - ) { + ): Promise> { const signPsbtCommandResult = await this._api.sendCommand( new SignPsbtCommand({ globalCommitments: psbtCommitment.globalCommitment, @@ -64,11 +66,12 @@ export class SignPsbtTask { }), ); - const continueTask = new ContinueTask(this._api); - const result = await continueTask.run(dataStore, signPsbtCommandResult); + const continueTask = new ContinueTask(this._api, dataStore); + const result = await continueTask.run(signPsbtCommandResult); if (isSuccessCommandResult(result)) { - return BtcCommandUtils.getSignature(result); + const signatureList = continueTask.getYieldedResults(); + return CommandResultFactory({ data: signatureList }); } return result; } From 808e5a65a43f00a1ea5dee65892e825546dd4f22 Mon Sep 17 00:00:00 2001 From: fAnselmi-Ledger Date: Wed, 18 Dec 2024 11:01:35 +0100 Subject: [PATCH 10/11] =?UTF-8?q?=E2=9C=A8=20(signer-btc):=20Implement=20g?= =?UTF-8?q?etWalletAddress=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/happy-zoos-search.md | 5 + .../signer/signer-btc/src/api/model/Wallet.ts | 4 + .../command/GetWalletAddressCommand.test.ts | 91 ++++---- .../command/GetWalletAddressCommand.ts | 30 +-- .../task/GetWalletAddressTask.test.ts | 196 ++++++++++++++++++ .../app-binder/task/GetWalletAddressTask.ts | 87 ++++++++ .../src/internal/utils/BtcCommandUtils.ts | 21 ++ 7 files changed, 362 insertions(+), 72 deletions(-) create mode 100644 .changeset/happy-zoos-search.md create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts diff --git a/.changeset/happy-zoos-search.md b/.changeset/happy-zoos-search.md new file mode 100644 index 000000000..f611593ce --- /dev/null +++ b/.changeset/happy-zoos-search.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit": minor +--- + +Implement getWalletAddress task diff --git a/packages/signer/signer-btc/src/api/model/Wallet.ts b/packages/signer/signer-btc/src/api/model/Wallet.ts index 1c48c99b0..b72b4d476 100644 --- a/packages/signer/signer-btc/src/api/model/Wallet.ts +++ b/packages/signer/signer-btc/src/api/model/Wallet.ts @@ -47,3 +47,7 @@ export class WalletPolicy { } export type Wallet = DefaultWallet | RegisteredWallet; + +export type WalletAddress = { + address: string; +}; diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts index 16e26aeb4..908861b67 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts @@ -4,12 +4,15 @@ import { isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; -import { BTC_APP_ERRORS, BtcAppCommandError } from "./utils/bitcoinAppErrors"; +import { SW_INTERRUPTED_EXECUTION } from "./utils/constants"; import { GetWalletAddressCommand, type GetWalletAddressCommandArgs, } from "./GetWalletAddressCommand"; +const SUCCESS_STATUS = new Uint8Array([0x90, 0x00]); +const USER_DENIED_STATUS = new Uint8Array([0x69, 0x85]); + describe("GetWalletAddressCommand", () => { let command: GetWalletAddressCommand; const defaultArgs: GetWalletAddressCommandArgs = { @@ -79,67 +82,61 @@ describe("GetWalletAddressCommand", () => { }); describe("parseResponse", () => { - it("should parse the response and extract the address", () => { - const responseData = Uint8Array.from("myAddressData", (c) => - c.charCodeAt(0), - ); - const response = new ApduResponse({ - statusCode: Uint8Array.from([0x90, 0x00]), - data: responseData, + it("should return the APDU response if it's a continue response", () => { + // given + const continueResponseData = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + + const apduResponse = new ApduResponse({ + statusCode: SW_INTERRUPTED_EXECUTION, + data: continueResponseData, }); - const result = command.parseResponse(response); + // when + const response = command.parseResponse(apduResponse); - expect(result).toStrictEqual( + // then + expect(response).toStrictEqual( CommandResultFactory({ - data: { - address: new TextDecoder().decode(responseData), - }, + data: apduResponse, }), ); }); - it("should return an error if response status code is an error code", () => { - const errorStatusCode = Uint8Array.from([0x69, 0x85]); - const response = new ApduResponse({ - statusCode: errorStatusCode, - data: new Uint8Array(), + it("should return an error if user denied the operation", () => { + // given + const apduResponse = new ApduResponse({ + statusCode: USER_DENIED_STATUS, + data: new Uint8Array([]), }); - const result = command.parseResponse(response); - - expect(isSuccessCommandResult(result)).toBe(false); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(BtcAppCommandError); - const error = result.error as BtcAppCommandError; - const expectedErrorInfo = BTC_APP_ERRORS["6985"]; - expect(expectedErrorInfo).toBeDefined(); - if (expectedErrorInfo) { - expect(error.message).toBe(expectedErrorInfo.message); - } - } else { - fail("Expected error"); + // when + const response = command.parseResponse(apduResponse); + + // then + expect(isSuccessCommandResult(response)).toBe(false); + if (!isSuccessCommandResult(response)) { + expect(response.error).toBeDefined(); } }); - it("should return an error if address cannot be extracted", () => { - const response = new ApduResponse({ - statusCode: Uint8Array.from([0x90, 0x00]), - data: new Uint8Array(), + it("should return correct data when response is not empty", () => { + // given + const responseData = Uint8Array.from("addressData", (c) => + c.charCodeAt(0), + ); + + const apduResponse = new ApduResponse({ + statusCode: SUCCESS_STATUS, + data: responseData, }); - const result = command.parseResponse(response); - - expect(isSuccessCommandResult(result)).toBe(false); - if (!isSuccessCommandResult(result)) { - expect(result.error.originalError).toEqual( - expect.objectContaining({ - message: "Failed to extract address from response", - }), - ); - } else { - fail("Expected error"); - } + // when + const response = command.parseResponse(apduResponse); + + // then + expect(response).toStrictEqual( + CommandResultFactory({ data: apduResponse }), + ); }); }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts index 9750d6f3e..e8eb97935 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts @@ -1,12 +1,10 @@ import { type Apdu, ApduBuilder, - ApduParser, type ApduResponse, type Command, type CommandResult, CommandResultFactory, - InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; import { CommandErrorHelper } from "@ledgerhq/signer-utils"; import { Maybe } from "purify-ts"; @@ -20,9 +18,7 @@ import { type BtcErrorCodes, } from "./utils/bitcoinAppErrors"; -export type GetWalletAddressCommandResponse = { - readonly address: string; -}; +export type GetWalletAddressCommandResponse = ApduResponse; export type GetWalletAddressCommandArgs = { readonly display: boolean; @@ -69,25 +65,9 @@ export class GetWalletAddressCommand parseResponse( response: ApduResponse, - ): CommandResult { - return Maybe.fromNullable( - this._errorHelper.getError(response), - ).orDefaultLazy(() => { - const parser = new ApduParser(response); - if (response.data.length === 0) { - return CommandResultFactory({ - error: new InvalidStatusWordError( - "Failed to extract address from response", - ), - }); - } - - const address = parser.encodeToString(response.data); - return CommandResultFactory({ - data: { - address, - }, - }); - }); + ): CommandResult { + return Maybe.fromNullable(this._errorHelper.getError(response)).orDefault( + CommandResultFactory({ data: response }), + ); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts new file mode 100644 index 000000000..8e58dfbf5 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts @@ -0,0 +1,196 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + type ApduResponse, + CommandResultFactory, + CommandResultStatus, + type InternalApi, + InvalidStatusWordError, +} from "@ledgerhq/device-management-kit"; +import { Left, Right } from "purify-ts"; + +import { ClientCommandHandlerError } from "@internal/app-binder/command/client-command-handlers/Errors"; +import { ContinueCommand } from "@internal/app-binder/command/ContinueCommand"; +import { GetWalletAddressCommand } from "@internal/app-binder/command/GetWalletAddressCommand"; +import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; +import { + ClientCommandCodes, + SW_INTERRUPTED_EXECUTION, +} from "@internal/app-binder/command/utils/constants"; +import { type Wallet } from "@internal/wallet/model/Wallet"; +import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; + +import { GetWalletAddressTask } from "./GetWalletAddressTask"; + +const DISPLAY = true; +const CHANGE = false; +const ADDRESS_INDEX = 0; +const TEST_ADDRESS = "bc1qexampleaddress"; +const REGISTERED_WALLET_ID = new Uint8Array(32).fill(0xaf); +const REGISTERED_WALLET_HMAC = new Uint8Array(32).fill(0xfa); + +const MOCK_WALLET: Wallet = { + hmac: REGISTERED_WALLET_HMAC, + name: "TestWallet", + descriptorTemplate: "wpkh([fingerprint/]/0h/0h/0h)", + keys: [], + //@ts-ignore + keysTree: {}, + descriptorBuffer: new Uint8Array(), +}; + +describe("GetWalletAddressTask", () => { + const apiMock = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + + const APDU_SUCCESS_RESPONSE: ApduResponse = { + statusCode: new Uint8Array([0x90, 0x00]), + data: new TextEncoder().encode(TEST_ADDRESS), + }; + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should return address if initial GET_WALLET_ADDRESS command succeeds", async () => { + // given + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce( + CommandResultFactory({ data: APDU_SUCCESS_RESPONSE }), + ); + + jest + .spyOn(DefaultWalletSerializer.prototype, "serialize") + .mockReturnValue(REGISTERED_WALLET_ID); + + // when + const result = await new GetWalletAddressTask(apiMock, { + display: DISPLAY, + wallet: MOCK_WALLET, + change: CHANGE, + addressIndex: ADDRESS_INDEX, + }).run(); + + // then + expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + expect(apiMock.sendCommand).toHaveBeenCalledWith( + expect.any(GetWalletAddressCommand), + ); + expect(result).toStrictEqual( + CommandResultFactory({ data: { address: TEST_ADDRESS } }), + ); + }); + + it("should handle interactive requests after an interrupted execution", async () => { + // given + (apiMock.sendCommand as jest.Mock) + .mockResolvedValueOnce( + CommandResultFactory({ + data: { + statusCode: SW_INTERRUPTED_EXECUTION, + data: new Uint8Array([ClientCommandCodes.YIELD]), + }, + }), + ) // first GET_WALLET_ADDRESS + .mockResolvedValueOnce( + CommandResultFactory({ data: APDU_SUCCESS_RESPONSE }), + ); // after CONTINUE + + jest + .spyOn(DefaultWalletSerializer.prototype, "serialize") + .mockReturnValue(REGISTERED_WALLET_ID); + + jest + .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") + .mockImplementation((request: Uint8Array, context: any) => { + // YIELD command + if (request[0] === ClientCommandCodes.YIELD) { + context.yieldedResults.push(new Uint8Array([])); + return Right(new Uint8Array([0x00])); + } + return Left(new ClientCommandHandlerError("Unexpected command")); + }); + + // when + const result = await new GetWalletAddressTask(apiMock, { + display: DISPLAY, + wallet: MOCK_WALLET, + change: CHANGE, + addressIndex: ADDRESS_INDEX, + }).run(); + + // then + expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); + expect(apiMock.sendCommand).toHaveBeenNthCalledWith( + 2, + expect.any(ContinueCommand), + ); + expect(result).toStrictEqual( + CommandResultFactory({ data: { address: TEST_ADDRESS } }), + ); + }); + + it("should fail if initial GET_WALLET_ADDRESS command fails", async () => { + // given + const getAddrFail = CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response from the device"), + }); + + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(getAddrFail); + + // when + const result = await new GetWalletAddressTask(apiMock, { + display: DISPLAY, + wallet: MOCK_WALLET, + change: CHANGE, + addressIndex: ADDRESS_INDEX, + }).run(); + + // then + expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + expect(result.status).toBe(CommandResultStatus.Error); + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response from the device"), + }), + ); + }); + + it("should fail if no address is extracted after all continuations", async () => { + // given + // continue response but never a final address + const continueResponse: ApduResponse = { + statusCode: SW_INTERRUPTED_EXECUTION, + data: new Uint8Array([ClientCommandCodes.YIELD]), + }; + + (apiMock.sendCommand as jest.Mock) + .mockResolvedValueOnce(CommandResultFactory({ data: continueResponse })) + .mockResolvedValueOnce(CommandResultFactory({ data: continueResponse })) + .mockResolvedValueOnce( + CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response from the device"), + }), + ); + + jest + .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") + .mockImplementation(() => Right(new Uint8Array([0x00]))); + + // when + const result = await new GetWalletAddressTask(apiMock, { + display: DISPLAY, + wallet: MOCK_WALLET, + change: CHANGE, + addressIndex: ADDRESS_INDEX, + }).run(); + + // then + expect(apiMock.sendCommand).toHaveBeenCalledTimes(3); + expect(result.status).toBe(CommandResultStatus.Error); + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response from the device"), + }), + ); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts new file mode 100644 index 000000000..f92c5530d --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts @@ -0,0 +1,87 @@ +import { + type CommandResult, + CommandResultFactory, + InvalidStatusWordError, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; +import { type InternalApi } from "@ledgerhq/device-management-kit"; + +import { type WalletAddress } from "@api/model/Wallet"; +import { GetWalletAddressCommand } from "@internal/app-binder/command/GetWalletAddressCommand"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { DataStore } from "@internal/data-store/model/DataStore"; +import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; +import { type Wallet } from "@internal/wallet/model/Wallet"; +import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; +import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +import { ContinueTask } from "./ContinueTask"; +import { PrepareWalletPolicyTask } from "./PrepareWalletPolicyTask"; + +export type SendGetWalletAddressTaskArgs = { + display: boolean; + wallet: Wallet; + change: boolean; + addressIndex: number; +}; + +export class GetWalletAddressTask { + private readonly _walletSerializer: WalletSerializer; + private readonly _dataStore: DataStore; + + constructor( + private api: InternalApi, + private args: SendGetWalletAddressTaskArgs, + walletSerializer?: WalletSerializer, + dataStore?: DataStore, + ) { + this._walletSerializer = + walletSerializer || + new DefaultWalletSerializer(new Sha256HasherService()); + this._dataStore = dataStore || new DataStore(); + } + + private async runPrepareWalletPolicy() { + return new PrepareWalletPolicyTask(this.api, { + wallet: this.args.wallet, + }).run(); + } + + private async runGetWalletAddressTask( + wallet: Wallet, + ): Promise> { + const { display, change, addressIndex } = this.args; + + const walletId = this._walletSerializer.serialize(wallet); + + const getWalletAddressInitialResponse = await this.api.sendCommand( + new GetWalletAddressCommand({ + display, + walletId, + walletHmac: wallet.hmac, + change, + addressIndex, + }), + ); + const response = await new ContinueTask(this.api, this._dataStore).run( + getWalletAddressInitialResponse, + ); + if (isSuccessCommandResult(response)) { + return BtcCommandUtils.getAddress(response); + } + + return CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response from the device"), + }); + } + + async run(): Promise> { + const walletPolicyResult = await this.runPrepareWalletPolicy(); + if (!isSuccessCommandResult(walletPolicyResult)) { + return walletPolicyResult; + } + + return this.runGetWalletAddressTask(walletPolicyResult.data); + } +} diff --git a/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts index c47754ed2..907d652d7 100644 --- a/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts +++ b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts @@ -9,6 +9,7 @@ import { } from "@ledgerhq/device-management-kit"; import { type Signature } from "@api/model/Signature"; +import { type WalletAddress } from "@api/model/Wallet"; import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { SW_INTERRUPTED_EXECUTION } from "@internal/app-binder/command/utils/constants"; @@ -69,4 +70,24 @@ export class BtcCommandUtils { }, }); } + + static getAddress( + response: CommandSuccessResult, + ): CommandResult { + const parser = new ApduParser(response.data); + if (response.data.data.length === 0) { + return CommandResultFactory({ + error: new InvalidStatusWordError( + "Failed to extract address from response", + ), + }); + } + + const address = parser.encodeToString(response.data.data); + return CommandResultFactory({ + data: { + address, + }, + }); + } } From 4d3fb61f696c9c16e07b277f8b525f89a1081617 Mon Sep 17 00:00:00 2001 From: fAnselmi-Ledger Date: Thu, 9 Jan 2025 10:06:04 +0100 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=A8=20(signer-btc):=20Implement=20g?= =?UTF-8?q?etWalletAddress=20device=20action=20and=20sample=20app=20getWal?= =?UTF-8?q?letAddress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fuzzy-eagles-cough.md | 5 + .../src/components/SignerBtcView/index.tsx | 44 +++ .../signer/signer-btc/src/api/SignerBtc.ts | 9 +- .../GetWalletAddressDeviceActionTypes.ts | 47 +++ packages/signer/signer-btc/src/api/index.ts | 1 + .../src/api/model/WalletAddressOptions.ts | 4 + .../src/internal/DefaultSignerBtc.ts | 18 +- .../src/internal/app-binder/BtcAppBinder.ts | 40 ++- .../GetExtendedPublicKeyCommand.test.ts | 10 +- .../command/GetWalletAddressCommand.test.ts | 8 +- .../command/GetWalletAddressCommand.ts | 4 +- .../GetWalletAddressDeviceAction.test.ts | 325 ++++++++++++++++++ .../GetWalletAddressDeviceAction.ts | 231 +++++++++++++ .../task/GetWalletAddressTask.test.ts | 48 +-- .../app-binder/task/GetWalletAddressTask.ts | 64 ++-- .../use-cases/di/useCasesModule.test.ts | 6 + .../internal/use-cases/di/useCasesModule.ts | 2 + .../internal/use-cases/di/useCasesTypes.ts | 1 + .../GetWalletAddressUseCase.test.ts | 48 +++ .../GetWalletAddressUseCase.ts | 32 ++ 20 files changed, 867 insertions(+), 80 deletions(-) create mode 100644 .changeset/fuzzy-eagles-cough.md create mode 100644 packages/signer/signer-btc/src/api/app-binder/GetWalletAddressDeviceActionTypes.ts create mode 100644 packages/signer/signer-btc/src/api/model/WalletAddressOptions.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/device-action/GetWalletAddress/GetWalletAddressDeviceAction.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/device-action/GetWalletAddress/GetWalletAddressDeviceAction.ts create mode 100644 packages/signer/signer-btc/src/internal/use-cases/get-wallet-address/GetWalletAddressUseCase.test.ts create mode 100644 packages/signer/signer-btc/src/internal/use-cases/get-wallet-address/GetWalletAddressUseCase.ts diff --git a/.changeset/fuzzy-eagles-cough.md b/.changeset/fuzzy-eagles-cough.md new file mode 100644 index 000000000..07cf2f449 --- /dev/null +++ b/.changeset/fuzzy-eagles-cough.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-bitcoin": minor +--- + +Implement get wallet device action and sample app getWalletAddress diff --git a/apps/sample/src/components/SignerBtcView/index.tsx b/apps/sample/src/components/SignerBtcView/index.tsx index a3746963b..11fb31221 100644 --- a/apps/sample/src/components/SignerBtcView/index.tsx +++ b/apps/sample/src/components/SignerBtcView/index.tsx @@ -5,6 +5,9 @@ import { type GetExtendedDAIntermediateValue, type GetExtendedPublicKeyDAError, type GetExtendedPublicKeyDAOutput, + type GetWalletAddressDAError, + type GetWalletAddressDAIntermediateValue, + type GetWalletAddressDAOutput, SignerBtcBuilder, type SignMessageDAError, type SignMessageDAIntermediateValue, @@ -114,6 +117,47 @@ export const SignerBtcView: React.FC<{ sessionId: string }> = ({ SignPsbtDAError, SignPsbtDAIntermediateValue >, + { + title: "Get wallet address", + description: + "Perform all the actions necessary to get the device's Bitcoin wallet address", + executeDeviceAction: ({ + checkOnDevice, + change, + addressIndex, + derivationPath, + }) => { + if (!signer) { + throw new Error("Signer not initialized"); + } + + return signer.getWalletAddress( + new DefaultWallet( + derivationPath, + DefaultDescriptorTemplate.NATIVE_SEGWIT, + ), + addressIndex, + { checkOnDevice, change }, + ); + }, + initialValues: { + checkOnDevice: false, + change: false, + derivationPath: DEFAULT_DERIVATION_PATH, + addressIndex: 0, + }, + deviceModelId, + } satisfies DeviceActionProps< + GetWalletAddressDAOutput, + { + checkOnDevice: boolean; + change: boolean; + addressIndex: number; + derivationPath: string; + }, + GetWalletAddressDAError, + GetWalletAddressDAIntermediateValue + >, ], [deviceModelId, signer], ); diff --git a/packages/signer/signer-btc/src/api/SignerBtc.ts b/packages/signer/signer-btc/src/api/SignerBtc.ts index c955bcd56..82d3693a5 100644 --- a/packages/signer/signer-btc/src/api/SignerBtc.ts +++ b/packages/signer/signer-btc/src/api/SignerBtc.ts @@ -5,6 +5,9 @@ import { type AddressOptions } from "@api/model/AddressOptions"; import { type Psbt } from "@api/model/Psbt"; import { type Wallet } from "@api/model/Wallet"; +import { type GetWalletAddressDAReturnType } from "./app-binder/GetWalletAddressDeviceActionTypes"; +import { WalletAddressOptions } from "./model/WalletAddressOptions"; + export interface SignerBtc { getExtendedPublicKey: ( derivationPath: string, @@ -15,6 +18,10 @@ export interface SignerBtc { message: string, ) => SignMessageDAReturnType; signPsbt: (wallet: Wallet, psbt: Psbt) => SignPsbtDAReturnType; - // getAddress: (wallet: Wallet, options?: AddressOptions) => Promise; + getWalletAddress: ( + wallet: Wallet, + addressIndex: number, + options: WalletAddressOptions, + ) => GetWalletAddressDAReturnType; // signTransaction: (wallet: Wallet, psbt: Psbt) => Promise; } diff --git a/packages/signer/signer-btc/src/api/app-binder/GetWalletAddressDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/GetWalletAddressDeviceActionTypes.ts new file mode 100644 index 000000000..10c49c6a8 --- /dev/null +++ b/packages/signer/signer-btc/src/api/app-binder/GetWalletAddressDeviceActionTypes.ts @@ -0,0 +1,47 @@ +import { + type CommandErrorResult, + type DeviceActionState, + type ExecuteDeviceActionReturnType, + type OpenAppDAError, + type OpenAppDARequiredInteraction, +} from "@ledgerhq/device-management-kit"; + +import { type WalletAddress } from "@api/model/Wallet"; +import { type Wallet as ApiWallet } from "@api/model/Wallet"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; + +export type GetWalletAddressDAOutput = WalletAddress; + +export type GetWalletAddressDAInput = { + checkOnDevice: boolean; + wallet: ApiWallet; + change: boolean; + addressIndex: number; +}; + +export type GetWalletAddressDAError = + | OpenAppDAError + | CommandErrorResult["error"]; + +type GetWalletAddressDARequiredInteraction = OpenAppDARequiredInteraction; + +export type GetWalletAddressDAIntermediateValue = { + requiredUserInteraction: GetWalletAddressDARequiredInteraction; +}; + +export type GetWalletAddressDAState = DeviceActionState< + GetWalletAddressDAOutput, + GetWalletAddressDAError, + GetWalletAddressDAIntermediateValue +>; + +export type GetWalletAddressDAInternalState = { + readonly error: GetWalletAddressDAError | null; + readonly walletAddress: WalletAddress | null; +}; + +export type GetWalletAddressDAReturnType = ExecuteDeviceActionReturnType< + GetWalletAddressDAOutput, + GetWalletAddressDAError, + GetWalletAddressDAIntermediateValue +>; diff --git a/packages/signer/signer-btc/src/api/index.ts b/packages/signer/signer-btc/src/api/index.ts index a1bdfb302..2e9dce71d 100644 --- a/packages/signer/signer-btc/src/api/index.ts +++ b/packages/signer/signer-btc/src/api/index.ts @@ -1,6 +1,7 @@ export { type SignerBtc } from "./SignerBtc"; export { SignerBtcBuilder } from "./SignerBtcBuilder"; export * from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; +export * from "@api/app-binder/GetWalletAddressDeviceActionTypes"; export type { SignMessageDAError, SignMessageDAInput, diff --git a/packages/signer/signer-btc/src/api/model/WalletAddressOptions.ts b/packages/signer/signer-btc/src/api/model/WalletAddressOptions.ts new file mode 100644 index 000000000..b072be698 --- /dev/null +++ b/packages/signer/signer-btc/src/api/model/WalletAddressOptions.ts @@ -0,0 +1,4 @@ +export type WalletAddressOptions = { + checkOnDevice?: boolean; + change?: boolean; +}; diff --git a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts index 8d48d8121..42c07d62c 100644 --- a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts +++ b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts @@ -8,11 +8,13 @@ import { type SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceA import { type AddressOptions } from "@api/model/AddressOptions"; import { type Psbt } from "@api/model/Psbt"; import { type Wallet } from "@api/model/Wallet"; +import { type WalletAddressOptions } from "@api/model/WalletAddressOptions"; 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 SignPsbtUseCase } from "@internal/use-cases/sign-psbt/SignPsbtUseCase"; +import { type GetWalletAddressUseCase } from "./use-cases/get-wallet-address/GetWalletAddressUseCase"; import { type SignMessageUseCase } from "./use-cases/sign-message/SignMessageUseCase"; import { makeContainer } from "./di"; @@ -28,6 +30,16 @@ export class DefaultSignerBtc implements SignerBtc { this._container = makeContainer({ dmk, sessionId }); } + getWalletAddress( + wallet: Wallet, + addressIndex: number, + { checkOnDevice = false, change = false }: WalletAddressOptions, + ) { + return this._container + .get(useCasesTypes.GetWalletAddressUseCase) + .execute(checkOnDevice, wallet, change, addressIndex); + } + signPsbt(wallet: Wallet, psbt: Psbt) { return this._container .get(useCasesTypes.SignPsbtUseCase) @@ -46,11 +58,11 @@ export class DefaultSignerBtc implements SignerBtc { } signMessage( - _derivationPath: string, - _message: string, + derivationPath: string, + message: string, ): SignMessageDAReturnType { return this._container .get(useCasesTypes.SignMessageUseCase) - .execute(_derivationPath, _message); + .execute(derivationPath, message); } } 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 ef70b3fed..d403b80c0 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts @@ -10,14 +10,23 @@ import { GetExtendedPublicKeyDAInput, GetExtendedPublicKeyDAReturnType, } from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; -import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; -import { SignPsbtDAReturnType } from "@api/app-binder/SignPsbtDeviceActionTypes"; -import { Psbt } from "@api/model/Psbt"; -import { Wallet } from "@api/model/Wallet"; +import { + GetWalletAddressDAInput, + GetWalletAddressDAReturnType, +} from "@api/app-binder/GetWalletAddressDeviceActionTypes"; +import { + SignMessageDAInput, + SignMessageDAReturnType, +} from "@api/app-binder/SignMessageDeviceActionTypes"; +import { + SignPsbtDAInput, + SignPsbtDAReturnType, +} from "@api/app-binder/SignPsbtDeviceActionTypes"; import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; import { SignPsbtDeviceAction } from "@internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction"; import { externalTypes } from "@internal/externalTypes"; +import { GetWalletAddressDeviceAction } from "./device-action/GetWalletAddress/GetWalletAddressDeviceAction"; import { SignMessageDeviceAction } from "./device-action/SignMessage/SignMessageDeviceAction"; @injectable() @@ -44,10 +53,7 @@ export class BtcAppBinder { }); } - signMessage(args: { - derivationPath: string; - message: string; - }): SignMessageDAReturnType { + signMessage(args: SignMessageDAInput): SignMessageDAReturnType { return this.dmk.executeDeviceAction({ sessionId: this.sessionId, deviceAction: new SignMessageDeviceAction({ @@ -59,7 +65,7 @@ export class BtcAppBinder { }); } - signPsbt(args: { psbt: Psbt; wallet: Wallet }): SignPsbtDAReturnType { + signPsbt(args: SignPsbtDAInput): SignPsbtDAReturnType { return this.dmk.executeDeviceAction({ sessionId: this.sessionId, deviceAction: new SignPsbtDeviceAction({ @@ -70,4 +76,20 @@ export class BtcAppBinder { }), }); } + + getWalletAddress( + args: GetWalletAddressDAInput, + ): GetWalletAddressDAReturnType { + return this.dmk.executeDeviceAction({ + sessionId: this.sessionId, + deviceAction: new GetWalletAddressDeviceAction({ + input: { + wallet: args.wallet, + checkOnDevice: args.checkOnDevice, + change: args.change, + addressIndex: args.addressIndex, + }, + }), + }); + } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts index 44bfda957..8ed194e14 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts @@ -14,12 +14,12 @@ import { type GetExtendedPublicKeyCommandArgs, } from "./GetExtendedPublicKeyCommand"; -const GET_EXTENDED_PUBLIC_KEY_APDU_WITH_DISPLAY = new Uint8Array([ +const GET_EXTENDED_PUBLIC_KEY_APDU_WITH_checkOnDevice = new Uint8Array([ 0xe1, 0x00, 0x00, 0x00, 0x0e, 0x01, 0x03, 0x80, 0x00, 0x00, 0x54, 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, ]); -const GET_EXTENDED_PUBLIC_KEY_APDU_WITHOUT_DISPLAY = new Uint8Array([ +const GET_EXTENDED_PUBLIC_KEY_APDU_WITHOUT_checkOnDevice = new Uint8Array([ 0xe1, 0x00, 0x00, 0x00, 0x0e, 0x00, 0x03, 0x80, 0x00, 0x00, 0x54, 0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, ]); @@ -60,11 +60,11 @@ describe("GetExtendedPublicKeyCommand", () => { //THEN expect(apdu.getRawApdu()).toEqual( - GET_EXTENDED_PUBLIC_KEY_APDU_WITH_DISPLAY, + GET_EXTENDED_PUBLIC_KEY_APDU_WITH_checkOnDevice, ); }); - it("should return the correct APDU without display", () => { + it("should return the correct APDU without checkOnDevice", () => { // GIVEN command = new GetExtendedPublicKeyCommand({ ...defaultArgs, @@ -76,7 +76,7 @@ describe("GetExtendedPublicKeyCommand", () => { //THEN expect(apdu.getRawApdu()).toEqual( - GET_EXTENDED_PUBLIC_KEY_APDU_WITHOUT_DISPLAY, + GET_EXTENDED_PUBLIC_KEY_APDU_WITHOUT_checkOnDevice, ); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts index 908861b67..dcf3abcc7 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts @@ -16,7 +16,7 @@ const USER_DENIED_STATUS = new Uint8Array([0x69, 0x85]); describe("GetWalletAddressCommand", () => { let command: GetWalletAddressCommand; const defaultArgs: GetWalletAddressCommandArgs = { - display: true, + checkOnDevice: true, walletId: Uint8Array.from("walletIdBuffer", (c) => c.charCodeAt(0)), walletHmac: Uint8Array.from("walletHmacBuffer", (c) => c.charCodeAt(0)), change: false, @@ -38,7 +38,7 @@ describe("GetWalletAddressCommand", () => { 0x00, // P1 0x01, // P2 0x24, // Length of data: 36 bytes - 0x01, // display: true + 0x01, // checkOnDevice: true ...Uint8Array.from("walletIdBuffer", (c) => c.charCodeAt(0)), ...Uint8Array.from("walletHmacBuffer", (c) => c.charCodeAt(0)), 0x00, // change: false @@ -52,7 +52,7 @@ describe("GetWalletAddressCommand", () => { it("should return correct APDU for different arguments", () => { const args: GetWalletAddressCommandArgs = { - display: false, + checkOnDevice: false, walletId: Uint8Array.from("anotherWalletId", (c) => c.charCodeAt(0)), walletHmac: Uint8Array.from("anotherWalletHmac", (c) => c.charCodeAt(0), @@ -68,7 +68,7 @@ describe("GetWalletAddressCommand", () => { 0x00, // P1 0x01, // P2 0x26, // Length of data - 0x00, // display: false + 0x00, // checkOnDevice: false ...Uint8Array.from("anotherWalletId", (c) => c.charCodeAt(0)), ...Uint8Array.from("anotherWalletHmac", (c) => c.charCodeAt(0)), 0x01, // change: true diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts index e8eb97935..4ee536851 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts @@ -21,7 +21,7 @@ import { export type GetWalletAddressCommandResponse = ApduResponse; export type GetWalletAddressCommandArgs = { - readonly display: boolean; + readonly checkOnDevice: boolean; readonly walletId: Uint8Array; readonly walletHmac: Uint8Array; readonly change: boolean; @@ -55,7 +55,7 @@ export class GetWalletAddressCommand p1: 0x00, p2: PROTOCOL_VERSION, }) - .addBufferToData(Uint8Array.from([this.args.display ? 1 : 0])) + .addBufferToData(Uint8Array.from([this.args.checkOnDevice ? 1 : 0])) .addBufferToData(this.args.walletId) .addBufferToData(this.args.walletHmac) .addBufferToData(Uint8Array.from([this.args.change ? 1 : 0])) diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/GetWalletAddress/GetWalletAddressDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/GetWalletAddress/GetWalletAddressDeviceAction.test.ts new file mode 100644 index 000000000..2bd11454b --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/GetWalletAddress/GetWalletAddressDeviceAction.test.ts @@ -0,0 +1,325 @@ +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 GetWalletAddressDAState } from "@api/app-binder/GetWalletAddressDeviceActionTypes"; +import { type RegisteredWallet, type WalletAddress } from "@api/model/Wallet"; +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 { GetWalletAddressDeviceAction as DeviceAction } from "./GetWalletAddressDeviceAction"; + +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("GetWalletAddressDeviceAction", () => { + const getWalletAddressMock = jest.fn(); + + function extractDependenciesMock() { + return { + getWalletAddress: getWalletAddressMock, + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("Success cases", () => { + it("should call external dependencies with the correct parameters and complete successfully", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new DeviceAction({ + input: { + checkOnDevice: true, + wallet: {} as unknown as RegisteredWallet, + change: false, + addressIndex: 0, + }, + }); + + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + const mockWalletAddress: WalletAddress = { + address: "mocked_address", + }; + getWalletAddressMock.mockResolvedValueOnce( + CommandResultFactory({ + data: mockWalletAddress, + }), + ); + + // Initial -> OpenApp -> CheckResult -> GetWalletAddress -> CheckResult -> Success + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + output: mockWalletAddress, + status: DeviceActionStatus.Completed, + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + + observable.subscribe({ + complete: () => { + expect(getWalletAddressMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + checkOnDevice: true, + wallet: {} as unknown as RegisteredWallet, + change: false, + addressIndex: 0, + }, + }), + ); + }, + }); + }); + }); + + describe("Error cases", () => { + it("should handle error if the open app fails", (done) => { + setupOpenAppDAMock( + new UnknownDeviceExchangeError("Mocked open app error"), + ); + + const deviceAction = new DeviceAction({ + input: { + checkOnDevice: true, + wallet: {} as unknown as RegisteredWallet, + change: false, + addressIndex: 0, + }, + }); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked open app error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should handle error if getWalletAddress fails with CommandErrorResult", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new DeviceAction({ + input: { + checkOnDevice: true, + wallet: {} as unknown as RegisteredWallet, + change: false, + addressIndex: 0, + }, + }); + + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + getWalletAddressMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new UnknownDeviceExchangeError( + "Mocked getWalletAddress error", + ), + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError( + "Mocked getWalletAddress error", + ), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should handle error if getWalletAddress throws an exception", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new DeviceAction({ + input: { + checkOnDevice: true, + wallet: {} as unknown as RegisteredWallet, + change: false, + addressIndex: 0, + }, + }); + + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + getWalletAddressMock.mockRejectedValueOnce( + new InvalidStatusWordError("Mocked exception from getWalletAddress"), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Error, + error: new InvalidStatusWordError( + "Mocked exception from getWalletAddress", + ), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("should return a Left if the final state has no walletAddress", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new DeviceAction({ + input: { + checkOnDevice: true, + wallet: {} as unknown as RegisteredWallet, + change: false, + addressIndex: 0, + }, + }); + + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + getWalletAddressMock.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.None, + }, + }, + { + 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/GetWalletAddress/GetWalletAddressDeviceAction.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/GetWalletAddress/GetWalletAddressDeviceAction.ts new file mode 100644 index 000000000..8ae700cd9 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/GetWalletAddress/GetWalletAddressDeviceAction.ts @@ -0,0 +1,231 @@ +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 GetWalletAddressDAError, + type GetWalletAddressDAInput, + type GetWalletAddressDAIntermediateValue, + type GetWalletAddressDAInternalState, + type GetWalletAddressDAOutput, +} from "@api/app-binder/GetWalletAddressDeviceActionTypes"; +import { type WalletAddress } from "@api/model/Wallet"; +import { type Wallet as ApiWallet } from "@api/model/Wallet"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { GetWalletAddressTask } from "@internal/app-binder/task/GetWalletAddressTask"; + +export type GetWalletAddressMachineDependencies = { + readonly getWalletAddress: (arg0: { + input: { + checkOnDevice: boolean; + wallet: ApiWallet; + change: boolean; + addressIndex: number; + }; + }) => Promise>; +}; + +export type ExtractGetWalletAddressMachineDependencies = ( + internalApi: InternalApi, +) => GetWalletAddressMachineDependencies; + +export class GetWalletAddressDeviceAction extends XStateDeviceAction< + GetWalletAddressDAOutput, + GetWalletAddressDAInput, + GetWalletAddressDAError, + GetWalletAddressDAIntermediateValue, + GetWalletAddressDAInternalState +> { + constructor(args: { input: GetWalletAddressDAInput; inspect?: boolean }) { + super(args); + } + + makeStateMachine( + internalApi: InternalApi, + ): DeviceActionStateMachine< + GetWalletAddressDAOutput, + GetWalletAddressDAInput, + GetWalletAddressDAError, + GetWalletAddressDAIntermediateValue, + GetWalletAddressDAInternalState + > { + type types = StateMachineTypes< + GetWalletAddressDAOutput, + GetWalletAddressDAInput, + GetWalletAddressDAError, + GetWalletAddressDAIntermediateValue, + GetWalletAddressDAInternalState + >; + + const { getWalletAddress } = 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), + getWalletAddress: fromPromise(getWalletAddress), + }, + 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: "GetWalletAddressDeviceAction", + initial: "OpenAppDeviceAction", + context: ({ input }) => { + return { + input, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + _internalState: { + error: null, + walletAddress: 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: "GetWalletAddress", + guard: "noInternalError", + }, + "Error", + ], + }, + GetWalletAddress: { + invoke: { + id: "getWalletAddress", + src: "getWalletAddress", + input: ({ context }) => ({ + checkOnDevice: context.input.checkOnDevice, + wallet: context.input.wallet, + change: context.input.change, + addressIndex: context.input.addressIndex, + }), + onDone: { + target: "GetWalletAddressResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + walletAddress: event.output.data, + }; + } + return { + ...context._internalState, + error: event.output.error, + }; + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + GetWalletAddressResultCheck: { + always: [ + { guard: "noInternalError", target: "Success" }, + { target: "Error" }, + ], + }, + Success: { + type: "final", + }, + Error: { + type: "final", + }, + }, + output: ({ context }) => + context._internalState.walletAddress + ? Right(context._internalState.walletAddress) + : Left( + context._internalState.error || + new UnknownDAError("No error in final state"), + ), + }); + } + + extractDependencies( + internalApi: InternalApi, + ): GetWalletAddressMachineDependencies { + const getWalletAddress = async (arg0: { + input: { + checkOnDevice: boolean; + wallet: ApiWallet; + change: boolean; + addressIndex: number; + }; + }): Promise> => { + return await new GetWalletAddressTask(internalApi, arg0.input).run(); + }; + return { + getWalletAddress, + }; + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts index 8e58dfbf5..ede94be25 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts @@ -6,10 +6,8 @@ import { type InternalApi, InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; -import { Left, Right } from "purify-ts"; +import { Right } from "purify-ts"; -import { ClientCommandHandlerError } from "@internal/app-binder/command/client-command-handlers/Errors"; -import { ContinueCommand } from "@internal/app-binder/command/ContinueCommand"; import { GetWalletAddressCommand } from "@internal/app-binder/command/GetWalletAddressCommand"; import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; import { @@ -21,7 +19,7 @@ import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletS import { GetWalletAddressTask } from "./GetWalletAddressTask"; -const DISPLAY = true; +const checkOnDevice = true; const CHANGE = false; const ADDRESS_INDEX = 0; const TEST_ADDRESS = "bc1qexampleaddress"; @@ -64,7 +62,7 @@ describe("GetWalletAddressTask", () => { // when const result = await new GetWalletAddressTask(apiMock, { - display: DISPLAY, + checkOnDevice: checkOnDevice, wallet: MOCK_WALLET, change: CHANGE, addressIndex: ADDRESS_INDEX, @@ -82,47 +80,26 @@ describe("GetWalletAddressTask", () => { it("should handle interactive requests after an interrupted execution", async () => { // given - (apiMock.sendCommand as jest.Mock) - .mockResolvedValueOnce( - CommandResultFactory({ - data: { - statusCode: SW_INTERRUPTED_EXECUTION, - data: new Uint8Array([ClientCommandCodes.YIELD]), - }, - }), - ) // first GET_WALLET_ADDRESS - .mockResolvedValueOnce( - CommandResultFactory({ data: APDU_SUCCESS_RESPONSE }), - ); // after CONTINUE + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce( + CommandResultFactory({ data: APDU_SUCCESS_RESPONSE }), + ); jest .spyOn(DefaultWalletSerializer.prototype, "serialize") .mockReturnValue(REGISTERED_WALLET_ID); - jest - .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") - .mockImplementation((request: Uint8Array, context: any) => { - // YIELD command - if (request[0] === ClientCommandCodes.YIELD) { - context.yieldedResults.push(new Uint8Array([])); - return Right(new Uint8Array([0x00])); - } - return Left(new ClientCommandHandlerError("Unexpected command")); - }); - // when const result = await new GetWalletAddressTask(apiMock, { - display: DISPLAY, + checkOnDevice: checkOnDevice, wallet: MOCK_WALLET, change: CHANGE, addressIndex: ADDRESS_INDEX, }).run(); // then - expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); - expect(apiMock.sendCommand).toHaveBeenNthCalledWith( - 2, - expect.any(ContinueCommand), + expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + expect(apiMock.sendCommand).toHaveBeenCalledWith( + expect.any(GetWalletAddressCommand), ); expect(result).toStrictEqual( CommandResultFactory({ data: { address: TEST_ADDRESS } }), @@ -139,7 +116,7 @@ describe("GetWalletAddressTask", () => { // when const result = await new GetWalletAddressTask(apiMock, { - display: DISPLAY, + checkOnDevice: checkOnDevice, wallet: MOCK_WALLET, change: CHANGE, addressIndex: ADDRESS_INDEX, @@ -157,7 +134,6 @@ describe("GetWalletAddressTask", () => { it("should fail if no address is extracted after all continuations", async () => { // given - // continue response but never a final address const continueResponse: ApduResponse = { statusCode: SW_INTERRUPTED_EXECUTION, data: new Uint8Array([ClientCommandCodes.YIELD]), @@ -178,7 +154,7 @@ describe("GetWalletAddressTask", () => { // when const result = await new GetWalletAddressTask(apiMock, { - display: DISPLAY, + checkOnDevice: checkOnDevice, wallet: MOCK_WALLET, change: CHANGE, addressIndex: ADDRESS_INDEX, diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts index f92c5530d..92d28e3a1 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts @@ -7,12 +7,17 @@ import { import { type InternalApi } from "@ledgerhq/device-management-kit"; import { type WalletAddress } from "@api/model/Wallet"; +import { type Wallet as ApiWallet } from "@api/model/Wallet"; import { GetWalletAddressCommand } from "@internal/app-binder/command/GetWalletAddressCommand"; import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { DataStore } from "@internal/data-store/model/DataStore"; +import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; +import { DefaultDataStoreService } from "@internal/data-store/service/DefaultDataStoreService"; +import { MerkleMapBuilder } from "@internal/merkle-tree/service/MerkleMapBuilder"; +import { MerkleTreeBuilder } from "@internal/merkle-tree/service/MerkleTreeBuilder"; import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; -import { type Wallet } from "@internal/wallet/model/Wallet"; +import { type Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; @@ -20,53 +25,67 @@ import { ContinueTask } from "./ContinueTask"; import { PrepareWalletPolicyTask } from "./PrepareWalletPolicyTask"; export type SendGetWalletAddressTaskArgs = { - display: boolean; - wallet: Wallet; + checkOnDevice: boolean; + wallet: ApiWallet; change: boolean; addressIndex: number; }; export class GetWalletAddressTask { - private readonly _walletSerializer: WalletSerializer; - private readonly _dataStore: DataStore; + private readonly walletSerializer: WalletSerializer; + private readonly dataStoreService: DataStoreService; constructor( - private api: InternalApi, - private args: SendGetWalletAddressTaskArgs, - walletSerializer?: WalletSerializer, - dataStore?: DataStore, + private readonly api: InternalApi, + private readonly args: SendGetWalletAddressTaskArgs, + walletSerializerFromArg?: WalletSerializer, ) { - this._walletSerializer = - walletSerializer || + this.walletSerializer = + walletSerializerFromArg || new DefaultWalletSerializer(new Sha256HasherService()); - this._dataStore = dataStore || new DataStore(); + + const merkleTreeBuilder = new MerkleTreeBuilder(new Sha256HasherService()); + const merkleMapBuilder = new MerkleMapBuilder(merkleTreeBuilder); + const walletSerializer = new DefaultWalletSerializer( + new Sha256HasherService(), + ); + + this.dataStoreService = new DefaultDataStoreService( + merkleTreeBuilder, + merkleMapBuilder, + walletSerializer, + new Sha256HasherService(), + ); } - private async runPrepareWalletPolicy() { + private async prepareWalletPolicy() { return new PrepareWalletPolicyTask(this.api, { wallet: this.args.wallet, }).run(); } private async runGetWalletAddressTask( - wallet: Wallet, + wallet: InternalWallet, + dataStore: DataStore, ): Promise> { - const { display, change, addressIndex } = this.args; + const { checkOnDevice, change, addressIndex } = this.args; - const walletId = this._walletSerializer.serialize(wallet); + const walletId = this.walletSerializer.getId(wallet); const getWalletAddressInitialResponse = await this.api.sendCommand( new GetWalletAddressCommand({ - display, + checkOnDevice, walletId, walletHmac: wallet.hmac, change, addressIndex, }), ); - const response = await new ContinueTask(this.api, this._dataStore).run( + + const response = await new ContinueTask(this.api, dataStore).run( getWalletAddressInitialResponse, ); + if (isSuccessCommandResult(response)) { return BtcCommandUtils.getAddress(response); } @@ -77,11 +96,16 @@ export class GetWalletAddressTask { } async run(): Promise> { - const walletPolicyResult = await this.runPrepareWalletPolicy(); + const walletPolicyResult = await this.prepareWalletPolicy(); + if (!isSuccessCommandResult(walletPolicyResult)) { return walletPolicyResult; } - return this.runGetWalletAddressTask(walletPolicyResult.data); + const dataStore = new DataStore(); + + this.dataStoreService.merklizeWallet(dataStore, walletPolicyResult.data); + + return this.runGetWalletAddressTask(walletPolicyResult.data, dataStore); } } diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.test.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.test.ts index fa1619860..23f919d38 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.test.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.test.ts @@ -26,5 +26,11 @@ describe("useCasesModuleFactory", () => { it("should bind SignMessageUseCase", () => { expect(container.isBound(useCasesTypes.SignMessageUseCase)).toBeTruthy(); }); + + it("should bind GetWalletAddressUseCase", () => { + expect( + container.isBound(useCasesTypes.GetWalletAddressUseCase), + ).toBeTruthy(); + }); }); }); diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts index b66e24168..c4331b460 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts @@ -2,6 +2,7 @@ import { ContainerModule } from "inversify"; import { useCasesTypes } from "@internal/use-cases/di/useCasesTypes"; import { GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase"; +import { GetWalletAddressUseCase } from "@internal/use-cases/get-wallet-address/GetWalletAddressUseCase"; import { SignMessageUseCase } from "@internal/use-cases/sign-message/SignMessageUseCase"; import { SignPsbtUseCase } from "@internal/use-cases/sign-psbt/SignPsbtUseCase"; @@ -21,5 +22,6 @@ export const useCasesModuleFactory = () => ); bind(useCasesTypes.SignMessageUseCase).to(SignMessageUseCase); bind(useCasesTypes.SignPsbtUseCase).to(SignPsbtUseCase); + bind(useCasesTypes.GetWalletAddressUseCase).to(GetWalletAddressUseCase); }, ); diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts index e2ebd8142..b38be385b 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts @@ -2,4 +2,5 @@ export const useCasesTypes = { GetExtendedPublicKeyUseCase: Symbol.for("GetExtendedPublicKeyUseCase"), SignMessageUseCase: Symbol.for("SignMessageUseCase"), SignPsbtUseCase: Symbol.for("SignPsbtUseCase"), + GetWalletAddressUseCase: Symbol.for("GetWalletAddressUseCase"), }; diff --git a/packages/signer/signer-btc/src/internal/use-cases/get-wallet-address/GetWalletAddressUseCase.test.ts b/packages/signer/signer-btc/src/internal/use-cases/get-wallet-address/GetWalletAddressUseCase.test.ts new file mode 100644 index 000000000..66372e49a --- /dev/null +++ b/packages/signer/signer-btc/src/internal/use-cases/get-wallet-address/GetWalletAddressUseCase.test.ts @@ -0,0 +1,48 @@ +import { type BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; +import { Leaf } from "@internal/merkle-tree/model/Leaf"; +import { MerkleTree } from "@internal/merkle-tree/model/MerkleTree"; + +import { GetWalletAddressUseCase } from "./GetWalletAddressUseCase"; + +describe("GetWalletAddressUseCase", () => { + it("should call getWalletAddress on appBinder with the correct arguments", () => { + // given + const wallet = { + name: "wallet-name", + descriptorTemplate: "wpkh(@0/**)", + keys: ["key1", "key2"], + hmac: new Uint8Array(32).fill(42), + keysTree: new MerkleTree( + new Leaf(new Uint8Array(), new Uint8Array(32).fill(7)), + [], + ), + descriptorBuffer: new Uint8Array(31).fill(16), + }; + const checkOnDevice = false; + const change = false; + const addressIndex = 0; + + const appBinder = { + getWalletAddress: jest.fn(), + }; + const getWalletAddressUseCase = new GetWalletAddressUseCase( + appBinder as unknown as BtcAppBinder, + ); + + // when + getWalletAddressUseCase.execute( + checkOnDevice, + wallet, + change, + addressIndex, + ); + + // then + expect(appBinder.getWalletAddress).toHaveBeenCalledWith({ + wallet, + checkOnDevice, + change, + addressIndex, + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/use-cases/get-wallet-address/GetWalletAddressUseCase.ts b/packages/signer/signer-btc/src/internal/use-cases/get-wallet-address/GetWalletAddressUseCase.ts new file mode 100644 index 000000000..371321026 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/use-cases/get-wallet-address/GetWalletAddressUseCase.ts @@ -0,0 +1,32 @@ +import { inject, injectable } from "inversify"; + +import { GetWalletAddressDAReturnType } from "@api/app-binder/GetWalletAddressDeviceActionTypes"; +import { Wallet } from "@api/model/Wallet"; +import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; +import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; + +@injectable() +export class GetWalletAddressUseCase { + private _appBinder: BtcAppBinder; + + constructor( + @inject(appBinderTypes.AppBinder) + appBinding: BtcAppBinder, + ) { + this._appBinder = appBinding; + } + + execute( + checkOnDevice: boolean, + wallet: Wallet, + change: boolean, + addressIndex: number, + ): GetWalletAddressDAReturnType { + return this._appBinder.getWalletAddress({ + wallet, + checkOnDevice, + change, + addressIndex, + }); + } +}