Skip to content

Commit

Permalink
✨ (signer-btc): Implement getWalletAddress task
Browse files Browse the repository at this point in the history
  • Loading branch information
fAnselmi-Ledger committed Jan 8, 2025
1 parent 0663f38 commit 808e5a6
Show file tree
Hide file tree
Showing 7 changed files with 362 additions and 72 deletions.
5 changes: 5 additions & 0 deletions .changeset/happy-zoos-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/device-management-kit": minor
---

Implement getWalletAddress task
4 changes: 4 additions & 0 deletions packages/signer/signer-btc/src/api/model/Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ export class WalletPolicy {
}

export type Wallet = DefaultWallet | RegisteredWallet;

export type WalletAddress = {
address: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 }),
);
});
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -69,25 +65,9 @@ export class GetWalletAddressCommand

parseResponse(
response: ApduResponse,
): CommandResult<GetWalletAddressCommandResponse, BtcErrorCodes> {
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<ApduResponse, BtcErrorCodes> {
return Maybe.fromNullable(this._errorHelper.getError(response)).orDefault(
CommandResultFactory({ data: response }),
);
}
}
Original file line number Diff line number Diff line change
@@ -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"),
}),
);
});
});
Loading

0 comments on commit 808e5a6

Please sign in to comment.