Skip to content

Commit

Permalink
✨ (btc-signer) [DSDK-465]: Add GetWalletAddressCommand (#512)
Browse files Browse the repository at this point in the history
  • Loading branch information
fAnselmi-Ledger authored Nov 25, 2024
2 parents 325359b + fc66f35 commit a877b80
Show file tree
Hide file tree
Showing 5 changed files with 356 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/quiet-feet-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/device-signer-kit-bitcoin": minor
---

Add GetWalletAddressCommand
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
ApduResponse,
CommandResultFactory,
isSuccessCommandResult,
} from "@ledgerhq/device-management-kit";

import {
BitcoinAppCommandError,
bitcoinAppErrors,
} from "./utils/bitcoinAppErrors";
import {
GetWalletAddressCommand,
type GetWalletAddressCommandArgs,
} from "./GetWalletAddressCommand";

describe("GetWalletAddressCommand", () => {
let command: GetWalletAddressCommand;
const defaultArgs: GetWalletAddressCommandArgs = {
display: true,
walletId: Uint8Array.from("walletIdBuffer", (c) => c.charCodeAt(0)),
walletHmac: Uint8Array.from("walletHmacBuffer", (c) => c.charCodeAt(0)),
change: false,
addressIndex: 0x00000000,
};

beforeEach(() => {
command = new GetWalletAddressCommand(defaultArgs);
jest.clearAllMocks();
jest.requireActual("@ledgerhq/device-management-kit");
});

describe("getApdu", () => {
it("should return correct APDU for default arguments", () => {
const apdu = command.getApdu();
const expectedApdu = Uint8Array.from([
0xe1, // CLA
0x03, // INS
0x00, // P1
0x01, // P2
0x24, // Length of data: 36 bytes
0x01, // display: true
...Uint8Array.from("walletIdBuffer", (c) => c.charCodeAt(0)),
...Uint8Array.from("walletHmacBuffer", (c) => c.charCodeAt(0)),
0x00, // change: false
0x00,
0x00,
0x00,
0x00, // addressIndex: 0x00000000
]);
expect(apdu.getRawApdu()).toEqual(expectedApdu);
});

it("should return correct APDU for different arguments", () => {
const args: GetWalletAddressCommandArgs = {
display: false,
walletId: Uint8Array.from("anotherWalletId", (c) => c.charCodeAt(0)),
walletHmac: Uint8Array.from("anotherWalletHmac", (c) =>
c.charCodeAt(0),
),
change: true,
addressIndex: 0x00000005,
};
command = new GetWalletAddressCommand(args);
const apdu = command.getApdu();
const expectedApdu = Uint8Array.from([
0xe1, // CLA
0x03, // INS
0x00, // P1
0x01, // P2
0x26, // Length of data
0x00, // display: false
...Uint8Array.from("anotherWalletId", (c) => c.charCodeAt(0)),
...Uint8Array.from("anotherWalletHmac", (c) => c.charCodeAt(0)),
0x01, // change: true
0x00,
0x00,
0x00,
0x05, // addressIndex: 0x00000005
]);
expect(apdu.getRawApdu()).toEqual(expectedApdu);
});
});

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,
});

const result = command.parseResponse(response);

expect(result).toStrictEqual(
CommandResultFactory({
data: {
address: new TextDecoder().decode(responseData),
},
}),
);
});

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(),
});

const result = command.parseResponse(response);

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(expectedErrorInfo).toBeDefined();
if (expectedErrorInfo) {
expect(error.message).toBe(expectedErrorInfo.message);
}
} else {
fail("Expected error");
}
});

it("should return an error if address cannot be extracted", () => {
const response = new ApduResponse({
statusCode: Uint8Array.from([0x90, 0x00]),
data: new Uint8Array(),
});

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");
}
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
type Apdu,
ApduBuilder,
ApduParser,
type ApduResponse,
type Command,
type CommandResult,
CommandResultFactory,
CommandUtils,
GlobalCommandErrorHandler,
InvalidStatusWordError,
isCommandErrorCode,
} from "@ledgerhq/device-management-kit";

import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants";

import {
BitcoinAppCommandError,
bitcoinAppErrors,
} from "./utils/bitcoinAppErrors";

export type GetWalletAddressCommandResponse = {
readonly address: string;
};

export type GetWalletAddressCommandArgs = {
readonly display: boolean;
readonly walletId: Uint8Array;
readonly walletHmac: Uint8Array;
readonly change: boolean;
readonly addressIndex: number;
};

export class GetWalletAddressCommand
implements
Command<GetWalletAddressCommandResponse, GetWalletAddressCommandArgs>
{
constructor(private readonly args: GetWalletAddressCommandArgs) {}

getApdu(): Apdu {
return new ApduBuilder({
cla: 0xe1,
ins: 0x03,
p1: 0x00,
p2: PROTOCOL_VERSION,
})
.addBufferToData(Uint8Array.from([this.args.display ? 1 : 0]))
.addBufferToData(this.args.walletId)
.addBufferToData(this.args.walletHmac)
.addBufferToData(Uint8Array.from([this.args.change ? 1 : 0]))
.add32BitUIntToData(this.args.addressIndex)
.build();
}

parseResponse(
response: ApduResponse,
): CommandResult<GetWalletAddressCommandResponse> {
const parser = new ApduParser(response);
const errorCode = parser.encodeToHexaString(response.statusCode);
if (isCommandErrorCode(errorCode, bitcoinAppErrors)) {
return CommandResultFactory<GetWalletAddressCommandResponse>({
error: new BitcoinAppCommandError({
...bitcoinAppErrors[errorCode],
errorCode,
}),
});
}

if (!CommandUtils.isSuccessResponse(response)) {
return CommandResultFactory({
error: GlobalCommandErrorHandler.handle(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,
},
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { DeviceExchangeError } from "@ledgerhq/device-management-kit";

import {
BitcoinAppCommandError,
type BitcoinAppErrorCodes,
bitcoinAppErrors,
} from "./bitcoinAppErrors";

describe("BitcoinAppCommandError", () => {
afterEach(() => {
jest.resetAllMocks();
});

afterAll(() => {
jest.resetModules();
});

it("should be an instance of DeviceExchangeError", () => {
const error = new BitcoinAppCommandError({
message: "Test error message",
errorCode: "6985",
});

expect(error).toBeInstanceOf(DeviceExchangeError);
});

it("should set the correct message when provided", () => {
const customMessage = "Custom error message";
const error = new BitcoinAppCommandError({
message: customMessage,
errorCode: "6985",
});

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({
message: "Either P1 or P2 is incorrect",
errorCode,
});

expect(error.customErrorCode).toBe(errorCode);
});

it("should correlate error codes with messages from bitcoinAppErrors", () => {
const errorCode: BitcoinAppErrorCodes = "6E00";
const expectedMessage = bitcoinAppErrors[errorCode].message;

const error = new BitcoinAppCommandError({
message: expectedMessage,
errorCode,
});

expect(error.customErrorCode).toBe(errorCode);
expect(error.message).toBe(expectedMessage);

expect(error).toBeInstanceOf(DeviceExchangeError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
type CommandErrors,
DeviceExchangeError,
type DmkError,
} from "@ledgerhq/device-management-kit";

export type BitcoinAppErrorCodes =
| "6985"
| "6A86"
| "6A87"
| "6D00"
| "6E00"
| "B000"
| "B007"
| "B008";

export const bitcoinAppErrors: CommandErrors<BitcoinAppErrorCodes> = {
"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" },
};

export class BitcoinAppCommandError
extends DeviceExchangeError<void>
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;
}
}

0 comments on commit a877b80

Please sign in to comment.