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 Dec 18, 2024
1 parent 6987863 commit 7d94a24
Show file tree
Hide file tree
Showing 4 changed files with 523 additions and 9 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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,6 +58,18 @@ export class GetWalletAddressCommand
parseResponse(
response: ApduResponse,
): CommandResult<GetWalletAddressCommandResponse> {
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)) {
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
}),
);
});
});
Loading

0 comments on commit 7d94a24

Please sign in to comment.