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..584884d91 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,12 @@ export class GetWalletAddressCommand parseResponse( response: ApduResponse, ): CommandResult { + if (BtcCommandUtils.isContinueResponse(response)) { + return CommandResultFactory({ + data: response, + }); + } + const parser = new ApduParser(response); const errorCode = parser.encodeToHexaString(response.statusCode); if (isCommandErrorCode(errorCode, bitcoinAppErrors)) { 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..a6f973231 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts @@ -0,0 +1,195 @@ +/* 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 addressResponse = CommandResultFactory({ + data: { address: TEST_ADDRESS }, + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should return address if initial GET_WALLET_ADDRESS command succeeds", async () => { + // GIVEN + (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(addressResponse); + + 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(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(DefaultWalletSerializer.prototype, "serialize") + .mockReturnValue(REGISTERED_WALLET_ID); + + jest + .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") + .mockImplementation((request: Uint8Array, context: any) => { + // Simulate 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(addressResponse); + }); + + it("should fail if initial GET_WALLET_ADDRESS command fails", async () => { + // GIVEN + const getAddrFail = CommandResultFactory({ + error: new InvalidStatusWordError("Failed"), + }); + + (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 initial GET_WALLET_ADDRESS response", + ), + }), + ); + }); + + it("should fail if no address is extracted after all continuations", async () => { + // GIVEN + // simulate a continue response but never get 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 })); + + jest + .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") + .mockImplementation(() => Right(new Uint8Array([0x00]))); + + // eventually we'll fail to retrieve a final address + (apiMock.sendCommand as jest.Mock).mockImplementationOnce(async () => { + return CommandResultFactory({ + error: new InvalidStatusWordError( + "Failed to get final wallet address response", + ), + }); + }); + + // 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( + "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..9135cd75f --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.ts @@ -0,0 +1,197 @@ +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 { 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 { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; +import { CommandUtils } from "@internal/utils/CommandUtils"; +import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; +import { type Wallet } from "@internal/wallet/model/Wallet"; +import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; + +export type SendGetWalletAddressTaskArgs = { + display: boolean; + wallet: Wallet; + change: boolean; + addressIndex: number; +}; + +export class GetWalletAddressTask { + constructor( + private api: InternalApi, + private args: SendGetWalletAddressTaskArgs, + ) {} + + async run(): Promise> { + const { display, wallet, change, addressIndex } = this.args; + + const dataStore = new DataStore(); + + const interpreter = new ClientCommandInterpreter(); + + const commandHandlersContext: ClientCommandContext = { + dataStore, + queue: [], + yieldedResults: [], + }; + + const walletSerializer = new DefaultWalletSerializer( + new Sha256HasherService(), + ); + + const walletId = walletSerializer.serialize(wallet); + + const getWalletAddressInitialResponse = await this.api.sendCommand( + new GetWalletAddressCommand({ + display, + walletId, + walletHmac: wallet.hmac, + 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, + }, + }); + } +}