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/internal/app-binder/command/GetWalletAddressCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.ts index ab549e6d6..9354181d5 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 @@ -13,15 +13,18 @@ import { } from "@ledgerhq/device-management-kit"; import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants"; +import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; import { BitcoinAppCommandError, bitcoinAppErrors, } from "./utils/bitcoinAppErrors"; -export type GetWalletAddressCommandResponse = { - readonly address: string; -}; +export type GetWalletAddressCommandResponse = + | { + readonly address: string; + } + | ApduResponse; export type GetWalletAddressCommandArgs = { readonly display: boolean; @@ -55,6 +58,18 @@ export class GetWalletAddressCommand parseResponse( 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)) { @@ -66,12 +81,6 @@ export class GetWalletAddressCommand }); } - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } - if (response.data.length === 0) { return CommandResultFactory({ error: new InvalidStatusWordError( 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..9eb68ce2a --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts @@ -0,0 +1,283 @@ +/* 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 { RegisterWalletAddressCommand } from "@internal/app-binder/command/RegisterWalletAddressCommand"; +import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; +import { + ClientCommandCodes, + SW_INTERRUPTED_EXECUTION, +} from "@internal/app-binder/command/utils/constants"; + +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(0x11); +const REGISTERED_WALLET_HMAC = new Uint8Array(32).fill(0x22); +const DEFAULT_ZERO = new Uint8Array(32); +const TEST_POLICY = new Uint8Array([0x01, 0x02, 0x03]); + +describe("GetWalletAddressTask", () => { + const apiMock = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + + const addressResponse = CommandResultFactory({ + data: { address: TEST_ADDRESS }, + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should register a new wallet if policy is provided but no hmac/id", async () => { + // GIVEN + const registerResp = CommandResultFactory({ + data: { + walletId: REGISTERED_WALLET_ID, + walletHmac: REGISTERED_WALLET_HMAC, + }, + }); + + const getAddrResp = CommandResultFactory({ + data: { address: TEST_ADDRESS }, + }); + + (apiMock.sendCommand as jest.Mock) + .mockResolvedValueOnce(registerResp) // register + .mockResolvedValueOnce(getAddrResp); // getWalletAddress + + // WHEN + const result = await new GetWalletAddressTask(apiMock, { + display: DISPLAY, + walletPolicy: TEST_POLICY, + // no walletHmac / walletId provided + change: CHANGE, + addressIndex: ADDRESS_INDEX, + }).run(); + + // THEN + expect(apiMock.sendCommand).toHaveBeenNthCalledWith( + 1, + expect.any(RegisterWalletAddressCommand), + ); + expect(apiMock.sendCommand).toHaveBeenNthCalledWith( + 2, + expect.any(GetWalletAddressCommand), + ); + expect(result).toStrictEqual(addressResponse); + }); + + it("should skip registration if walletId and walletHmac are provided", async () => { + // GIVEN + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(addressResponse); + + // WHEN + const result = await new GetWalletAddressTask(apiMock, { + display: DISPLAY, + walletPolicy: TEST_POLICY, + walletId: REGISTERED_WALLET_ID, + walletHmac: REGISTERED_WALLET_HMAC, + change: CHANGE, + addressIndex: ADDRESS_INDEX, + }).run(); + + // THEN + expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + expect(apiMock.sendCommand).toHaveBeenCalledWith( + expect.any(GetWalletAddressCommand), + ); + expect(result).toStrictEqual(addressResponse); + }); + + it("should use zeroed HMAC and ID if no walletPolicy is provided (default wallet)", async () => { + // GIVEN + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(addressResponse); + + // WHEN + const result = await new GetWalletAddressTask(apiMock, { + display: DISPLAY, + change: CHANGE, + addressIndex: ADDRESS_INDEX, + // no walletPolicy, no hmac/id + }).run(); + + // THEN + expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + expect(apiMock.sendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + args: { + display: DISPLAY, + walletId: DEFAULT_ZERO, + walletHmac: DEFAULT_ZERO, + change: CHANGE, + addressIndex: ADDRESS_INDEX, + }, + }), + ); + expect(result).toStrictEqual(addressResponse); + }); + + 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(addressResponse); // after CONTINUE + + jest + .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") + .mockImplementation((request: Uint8Array, context: any) => { + // YIELD command simulation + 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, + walletPolicy: TEST_POLICY, + walletId: REGISTERED_WALLET_ID, + walletHmac: REGISTERED_WALLET_HMAC, + change: CHANGE, + addressIndex: ADDRESS_INDEX, + }).run(); + + // THEN + expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); + expect(apiMock.sendCommand).toHaveBeenNthCalledWith( + 2, + expect.any(ContinueCommand), + ); + + expect(result).toStrictEqual(addressResponse); + }); + + it("should fail if wallet registration fails", async () => { + // GIVEN + const registerFail = CommandResultFactory({ + error: new InvalidStatusWordError("Failed"), + }); + + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(registerFail); + + // WHEN + const result = await new GetWalletAddressTask(apiMock, { + display: DISPLAY, + walletPolicy: TEST_POLICY, // triggers registration + 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("Failed to register wallet address"), + }), + ); + }); + + it("should fail if initial GET_WALLET_ADDRESS command fails", async () => { + // GIVEN + const getAddrFail = CommandResultFactory({ + error: new InvalidStatusWordError("Failed"), + }); + + // no policy means default wallet + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(getAddrFail); + + // WHEN + const result = await new GetWalletAddressTask(apiMock, { + display: DISPLAY, + 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 initial GET_WALLET_ADDRESS response", + ), + }), + ); + }); + + it("should fail if no address is extracted after all continuations", async () => { + // GIVEN + // simulate that we get a continue response but never a final address + const continueResponse: ApduResponse = { + statusCode: SW_INTERRUPTED_EXECUTION, + data: new Uint8Array([ClientCommandCodes.YIELD]), + }; + + // then after responding, we get another continue, and so on... + (apiMock.sendCommand as jest.Mock) + .mockResolvedValueOnce(CommandResultFactory({ data: continueResponse })) + .mockResolvedValueOnce(CommandResultFactory({ data: continueResponse })); + + jest + .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") + .mockImplementation(() => Right(new Uint8Array([0x00]))); + + // eventually will return an error once it ends processing + + // WHEN + const task = new GetWalletAddressTask(apiMock, { + display: DISPLAY, + walletPolicy: TEST_POLICY, + walletId: REGISTERED_WALLET_ID, + walletHmac: REGISTERED_WALLET_HMAC, + change: CHANGE, + addressIndex: ADDRESS_INDEX, + }); + + // note: it won't infinitely loop since eventually we'd run out of test mocks + // let's just assume we've hit a scenario with no final address and we get a final error + + (apiMock.sendCommand as jest.Mock).mockImplementationOnce(async () => { + // first call was done above, second call is here + return CommandResultFactory({ + error: new InvalidStatusWordError( + "Failed to get final wallet address response", + ), + }); + }); + + const result = await task.run(); + expect(apiMock.sendCommand).toHaveBeenCalledTimes(3); + expect(result.status).toBe(CommandResultStatus.Error); + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError( + "Failed to get final wallet address response", + ), + }), + ); + }); +}); 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..d2e3fb829 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts @@ -0,0 +1,217 @@ +import { + ApduParser, + type ApduResponse, + type CommandResult, + CommandResultFactory, + GlobalCommandErrorHandler, + InvalidStatusWordError, + isCommandErrorCode, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; +import { type InternalApi } from "@ledgerhq/device-management-kit"; + +import { type ClientCommandContext } from "@internal/app-binder/command/client-command-handlers/ClientCommandHandlersTypes"; +import { ContinueCommand } from "@internal/app-binder/command/ContinueCommand"; +import { + GetWalletAddressCommand, + type GetWalletAddressCommandResponse, +} from "@internal/app-binder/command/GetWalletAddressCommand"; +import { RegisterWalletAddressCommand } from "@internal/app-binder/command/RegisterWalletAddressCommand"; +import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; +import { + BitcoinAppCommandError, + bitcoinAppErrors, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { DataStore } from "@internal/data-store/model/DataStore"; +import { CommandUtils } from "@internal/utils/CommandUtils"; +import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; + +export type SendGetWalletAddressTaskArgs = { + display: boolean; + walletPolicy?: Uint8Array; + walletHmac?: Uint8Array; + walletId?: Uint8Array; + change: boolean; + addressIndex: number; +}; + +export class GetWalletAddressTask { + constructor( + private api: InternalApi, + private args: SendGetWalletAddressTaskArgs, + ) {} + + async run(): Promise> { + const { display, walletPolicy, change, addressIndex } = this.args; + let { walletHmac, walletId } = this.args; + + // if we have a walletPolicy but no HMAC (and no walletId), we need to register the wallet. + if (walletPolicy && (!walletHmac || !walletId)) { + const registerWalletResp = await this.api.sendCommand( + new RegisterWalletAddressCommand({ walletPolicy }), + ); + + if (!isSuccessCommandResult(registerWalletResp)) { + return CommandResultFactory({ + error: new InvalidStatusWordError( + "Failed to register wallet address", + ), + }); + } + + walletId = registerWalletResp.data.walletId; + walletHmac = registerWalletResp.data.walletHmac; + } + + // if after this, we still don't have a walletHmac or walletId, + // it means we're dealing with a default wallet. + if (!walletHmac) { + walletHmac = new Uint8Array(32); // zeroed HMAC + } + if (!walletId) { + walletId = new Uint8Array(32); // zeroed wallet ID + } + + const dataStore = new DataStore(); + const interpreter = new ClientCommandInterpreter(); + const commandHandlersContext: ClientCommandContext = { + dataStore, + queue: [], + yieldedResults: [], + }; + + const getWalletAddressInitialResponse = await this.api.sendCommand( + new GetWalletAddressCommand({ + display, + walletId, + walletHmac, + change, + addressIndex, + }), + ); + if (!isSuccessCommandResult(getWalletAddressInitialResponse)) { + return CommandResultFactory({ + error: new InvalidStatusWordError( + "Invalid initial GET_WALLET_ADDRESS response", + ), + }); + } + + if ( + this.isGetWalletAddressCommandResponse( + getWalletAddressInitialResponse.data, + ) + ) { + return CommandResultFactory({ + data: getWalletAddressInitialResponse.data, + }); + } + + let currentResponse = getWalletAddressInitialResponse; + while ( + this.isApduResponse(currentResponse.data) && + BtcCommandUtils.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.parseGetWalletAddressFinalResponse, + ), + ); + + if (!isSuccessCommandResult(nextResponse)) { + return CommandResultFactory({ + error: new InvalidStatusWordError( + "Failed to get final wallet address response", + ), + }); + } + + if (this.isGetWalletAddressCommandResponse(nextResponse.data)) { + return CommandResultFactory({ + data: nextResponse.data, + }); + } + + currentResponse = nextResponse; + } + } + + return CommandResultFactory({ + error: new InvalidStatusWordError("Failed to retrieve wallet address."), + }); + } + + private isGetWalletAddressCommandResponse( + response: GetWalletAddressCommandResponse | ApduResponse, + ): response is GetWalletAddressCommandResponse { + return typeof response === "object" && "address" in response; + } + + private isApduResponse( + response: GetWalletAddressCommandResponse | ApduResponse, + ): response is ApduResponse { + return ( + typeof response === "object" && + "statusCode" in response && + "data" in response + ); + } + + private parseGetWalletAddressFinalResponse( + 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, + }), + }); + } + + 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, + }, + }); + } +}