-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ (signer-btc): Implement getWalletAddress task
- Loading branch information
1 parent
6987863
commit 7d94a24
Showing
4 changed files
with
523 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@ledgerhq/device-management-kit": minor | ||
--- | ||
|
||
Implement getWalletAddress task |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
283 changes: 283 additions & 0 deletions
283
packages/signer/signer-btc/src/internal/app-binder/task/GetWalletAddressTask.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
), | ||
}), | ||
); | ||
}); | ||
}); |
Oops, something went wrong.