diff --git a/.changeset/blue-lemons-impress.md b/.changeset/blue-lemons-impress.md new file mode 100644 index 000000000..219dfe3fd --- /dev/null +++ b/.changeset/blue-lemons-impress.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit": patch +--- + +Fix CommandUtils static calls diff --git a/.changeset/breezy-plums-run.md b/.changeset/breezy-plums-run.md new file mode 100644 index 000000000..4a8c713e0 --- /dev/null +++ b/.changeset/breezy-plums-run.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/signer-utils": patch +--- + +Create CommandErrorHelper to handle command errors diff --git a/.changeset/nervous-points-judge.md b/.changeset/nervous-points-judge.md new file mode 100644 index 000000000..b88122666 --- /dev/null +++ b/.changeset/nervous-points-judge.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-bitcoin": minor +--- + +Create SignPsbt API diff --git a/.changeset/odd-spies-chew.md b/.changeset/odd-spies-chew.md new file mode 100644 index 000000000..b3bacf612 --- /dev/null +++ b/.changeset/odd-spies-chew.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit": patch +--- + +Expose CommandSuccessResult diff --git a/.changeset/young-horses-wonder.md b/.changeset/young-horses-wonder.md new file mode 100644 index 000000000..5cce71ef0 --- /dev/null +++ b/.changeset/young-horses-wonder.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-bitcoin": patch +--- + +Use CommandErrorHelper in BTC commands diff --git a/apps/sample/package.json b/apps/sample/package.json index 097952ec3..9a8909e37 100644 --- a/apps/sample/package.json +++ b/apps/sample/package.json @@ -19,9 +19,9 @@ "@ledgerhq/device-management-kit": "workspace:*", "@ledgerhq/device-management-kit-flipper-plugin-client": "workspace:*", "@ledgerhq/device-mockserver-client": "workspace:*", + "@ledgerhq/device-signer-kit-bitcoin": "workspace:*", "@ledgerhq/device-signer-kit-ethereum": "workspace:*", "@ledgerhq/device-signer-kit-solana": "workspace:*", - "@ledgerhq/device-signer-kit-bitcoin": "workspace:*", "@ledgerhq/device-transport-kit-mockserver": "workspace:*", "@ledgerhq/device-transport-kit-web-ble": "workspace:*", "@ledgerhq/device-transport-kit-web-hid": "workspace:*", diff --git a/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx b/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx index e143bd473..4c30add87 100644 --- a/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx +++ b/apps/sample/src/components/DeviceActionsView/DeviceActionResponse.tsx @@ -7,6 +7,7 @@ import { } from "@ledgerhq/device-management-kit"; import { Flex, Icons, Tag, Text, Tooltip } from "@ledgerhq/react-ui"; import styled from "styled-components"; +import { inspect } from "util"; import { type FieldType } from "@/hooks/useForm"; @@ -91,11 +92,11 @@ export function DeviceActionResponse< wordBreak: "break-word", }} > - {JSON.stringify( - isError ? props.error : props.deviceActionState, - null, - 2, - )} + {isError + ? inspect(props.error, { depth: null }) + : props.deviceActionState.status === DeviceActionStatus.Error + ? inspect(props.deviceActionState.error, { depth: null }) + : JSON.stringify(props.deviceActionState, null, 2)} {!isError && props.deviceActionState.status === DeviceActionStatus.Pending ? ( diff --git a/apps/sample/src/components/DeviceActionsView/DeviceActionTester.tsx b/apps/sample/src/components/DeviceActionsView/DeviceActionTester.tsx index ea78a83d7..f2037a7b1 100644 --- a/apps/sample/src/components/DeviceActionsView/DeviceActionTester.tsx +++ b/apps/sample/src/components/DeviceActionsView/DeviceActionTester.tsx @@ -45,6 +45,12 @@ export type DeviceActionProps< debug?: boolean, ) => ExecuteDeviceActionReturnType; initialValues: Input; + InputValuesComponent?: React.FC<{ + initialValues: Input; + onChange: (values: Input) => void; + valueSelector?: ValueSelector; + disabled?: boolean; + }>; validateValues?: (args: Input) => boolean; valueSelector?: ValueSelector; deviceModelId: DeviceModelId; @@ -94,6 +100,7 @@ export function DeviceActionTester< executeDeviceAction, valueSelector, validateValues, + InputValuesComponent, } = props; const nonce = useRef(-1); @@ -204,12 +211,21 @@ export function DeviceActionTester< rowGap={3} pointerEvents={loading ? "none" : "auto"} > - + {InputValuesComponent ? ( + + ) : ( + + )} = { + [DefaultDescriptorTemplate.TAPROOT]: "86'/0'/0'", + [DefaultDescriptorTemplate.NATIVE_SEGWIT]: "84'/0'/0'", + [DefaultDescriptorTemplate.NESTED_SEGWIT]: "49'/0'/0'", + [DefaultDescriptorTemplate.LEGACY]: "44'/0'/0'", +}; + +const descriptorTemplateToLabel = { + [DefaultDescriptorTemplate.TAPROOT]: "Taproot", + [DefaultDescriptorTemplate.NATIVE_SEGWIT]: "Native Segwit", + [DefaultDescriptorTemplate.NESTED_SEGWIT]: "Nested Segwit", + [DefaultDescriptorTemplate.LEGACY]: "Legacy", +}; + +export const SignPsbtDAInputValuesForm: React.FC<{ + initialValues: SignPsbtInputValuesType; + onChange: (values: SignPsbtInputValuesType) => void; + disabled?: boolean; +}> = ({ initialValues, onChange, disabled }) => { + const { formValues, setFormValues, setFormValue } = useForm(initialValues); + + useEffect(() => { + onChange(formValues); + }, [formValues, onChange]); + + const onWalletDescriptorTemplateChange = useCallback( + (value: DefaultDescriptorTemplate) => { + const newValues = { + path: descriptorTemplateToDerivationPath[value], + descriptorTemplate: value, + }; + setFormValues((prev) => ({ ...prev, ...newValues })); + }, + [setFormValues], + ); + + return ( + + + Wallet address type + ({ + label: descriptorTemplateToLabel[value], + value, + }), + )} + value={{ + label: descriptorTemplateToLabel[formValues.descriptorTemplate], + value: formValues.descriptorTemplate, + }} + isMulti={false} + isSearchable={false} + onChange={(newVal) => + newVal && onWalletDescriptorTemplateChange(newVal.value) + } + /> + + + setFormValue("path", newVal)} + disabled={disabled} + data-testid="input-text_path" + /> + + setFormValue("psbt", newVal)} + disabled={disabled} + data-testid="input-text_psbt" + /> + + ); +}; diff --git a/apps/sample/src/components/SignerBtcView/index.tsx b/apps/sample/src/components/SignerBtcView/index.tsx index 85227d8cb..26700ddcc 100644 --- a/apps/sample/src/components/SignerBtcView/index.tsx +++ b/apps/sample/src/components/SignerBtcView/index.tsx @@ -1,5 +1,7 @@ import React, { useMemo } from "react"; import { + DefaultDescriptorTemplate, + DefaultWallet, type GetExtendedDAIntermediateValue, type GetExtendedPublicKeyDAError, type GetExtendedPublicKeyDAOutput, @@ -7,10 +9,14 @@ import { type SignMessageDAError, type SignMessageDAIntermediateValue, type SignMessageDAOutput, + type SignPsbtDAError, + type SignPsbtDAIntermediateValue, + type SignPsbtDAOutput, } from "@ledgerhq/device-signer-kit-bitcoin"; import { DeviceActionsList } from "@/components/DeviceActionsView/DeviceActionsList"; import { type DeviceActionProps } from "@/components/DeviceActionsView/DeviceActionTester"; +import { SignPsbtDAInputValuesForm } from "@/components/SignerBtcView/SignPsbtDAInputValusForm"; import { useDmk } from "@/providers/DeviceManagementKitProvider"; const DEFAULT_DERIVATION_PATH = "84'/0'/0'"; @@ -78,6 +84,37 @@ export const SignerBtcView: React.FC<{ sessionId: string }> = ({ SignMessageDAError, SignMessageDAIntermediateValue >, + { + title: "Sign psbt", + description: + "Perform all the actions necessary to sign a PSBT with the device", + executeDeviceAction: ({ descriptorTemplate, psbt, path }) => { + if (!signer) { + throw new Error("Signer not initialized"); + } + + return signer.signPsbt( + new DefaultWallet(path, descriptorTemplate), + psbt, + ); + }, + InputValuesComponent: SignPsbtDAInputValuesForm, + initialValues: { + descriptorTemplate: DefaultDescriptorTemplate.NATIVE_SEGWIT, + psbt: "", + path: DEFAULT_DERIVATION_PATH, + }, + deviceModelId, + } satisfies DeviceActionProps< + SignPsbtDAOutput, + { + psbt: string; + path: string; + descriptorTemplate: DefaultDescriptorTemplate; + }, + SignPsbtDAError, + SignPsbtDAIntermediateValue + >, ], [deviceModelId, signer], ); diff --git a/apps/sample/src/hooks/useForm.tsx b/apps/sample/src/hooks/useForm.tsx index fcc818c3f..1943a1a91 100644 --- a/apps/sample/src/hooks/useForm.tsx +++ b/apps/sample/src/hooks/useForm.tsx @@ -15,5 +15,6 @@ export function useForm>( return { formValues, setFormValue, + setFormValues, }; } diff --git a/apps/sample/src/styles/globalstyles.tsx b/apps/sample/src/styles/globalstyles.tsx index d7db8e5e7..89341ef5a 100644 --- a/apps/sample/src/styles/globalstyles.tsx +++ b/apps/sample/src/styles/globalstyles.tsx @@ -12,7 +12,7 @@ export const GlobalStyle = createGlobalStyle` background-color: #000000; } body { - user-select: none; + user-select: text; } .no-scrollbar { &::-webkit-scrollbar { diff --git a/packages/device-management-kit/src/api/command/model/CommandResult.ts b/packages/device-management-kit/src/api/command/model/CommandResult.ts index e584f99ce..27294d88f 100644 --- a/packages/device-management-kit/src/api/command/model/CommandResult.ts +++ b/packages/device-management-kit/src/api/command/model/CommandResult.ts @@ -14,7 +14,7 @@ export enum CommandResultStatus { Error = "ERROR", Success = "SUCCESS", } -type CommandSuccessResult = { +export type CommandSuccessResult = { status: CommandResultStatus.Success; data: Data; }; diff --git a/packages/device-management-kit/src/api/command/utils/CommandUtils.ts b/packages/device-management-kit/src/api/command/utils/CommandUtils.ts index 3b51440a4..1bab7acbf 100644 --- a/packages/device-management-kit/src/api/command/utils/CommandUtils.ts +++ b/packages/device-management-kit/src/api/command/utils/CommandUtils.ts @@ -6,7 +6,7 @@ export class CommandUtils { } static isSuccessResponse({ statusCode }: ApduResponse) { - if (!this.isValidStatusCode(statusCode)) { + if (!CommandUtils.isValidStatusCode(statusCode)) { return false; } @@ -14,7 +14,7 @@ export class CommandUtils { } static isLockedDeviceResponse({ statusCode }: ApduResponse) { - if (!this.isValidStatusCode(statusCode)) { + if (!CommandUtils.isValidStatusCode(statusCode)) { return false; } diff --git a/packages/device-management-kit/src/api/types.ts b/packages/device-management-kit/src/api/types.ts index d0a72831c..ad23c71a1 100644 --- a/packages/device-management-kit/src/api/types.ts +++ b/packages/device-management-kit/src/api/types.ts @@ -16,6 +16,7 @@ export type { Command } from "@api/command/Command"; export type { CommandErrorResult, CommandResult, + CommandSuccessResult, } from "@api/command/model/CommandResult"; export type { SendCommandUseCaseArgs } from "@api/command/use-case/SendCommandUseCase"; export type { DeviceModelId } from "@api/device/DeviceModel"; diff --git a/packages/signer/signer-btc/src/api/SignerBtc.ts b/packages/signer/signer-btc/src/api/SignerBtc.ts index 1bc3ee428..c955bcd56 100644 --- a/packages/signer/signer-btc/src/api/SignerBtc.ts +++ b/packages/signer/signer-btc/src/api/SignerBtc.ts @@ -1,23 +1,20 @@ -// import { type AddressOptions } from "@api/model/AddressOptions"; -// import { type Psbt } from "@api/model/Psbt"; -// import { type Signature } from "@api/model/Signature"; -// import { type Wallet } from "@api/model/Wallet"; +import { type GetExtendedPublicKeyDAReturnType } from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; +import { type SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; +import { type SignPsbtDAReturnType } from "@api/app-binder/SignPsbtDeviceActionTypes"; import { type AddressOptions } from "@api/model/AddressOptions"; -import { - type GetExtendedPublicKeyReturnType, - type SignMessageDAReturnType, -} from "@root/src"; +import { type Psbt } from "@api/model/Psbt"; +import { type Wallet } from "@api/model/Wallet"; export interface SignerBtc { getExtendedPublicKey: ( derivationPath: string, options: AddressOptions, - ) => GetExtendedPublicKeyReturnType; + ) => GetExtendedPublicKeyDAReturnType; signMessage: ( derivationPath: string, message: string, ) => SignMessageDAReturnType; + signPsbt: (wallet: Wallet, psbt: Psbt) => SignPsbtDAReturnType; // getAddress: (wallet: Wallet, options?: AddressOptions) => Promise; - // signPsbt: (wallet: Wallet, psbt: Psbt) => Promise; // signTransaction: (wallet: Wallet, psbt: Psbt) => Promise; } diff --git a/packages/signer/signer-btc/src/api/app-binder/GetExtendedPublicKeyDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/GetExtendedPublicKeyDeviceActionTypes.ts index 204dc5aae..436238cbe 100644 --- a/packages/signer/signer-btc/src/api/app-binder/GetExtendedPublicKeyDeviceActionTypes.ts +++ b/packages/signer/signer-btc/src/api/app-binder/GetExtendedPublicKeyDeviceActionTypes.ts @@ -10,6 +10,7 @@ import { type GetExtendedPublicKeyCommandArgs, type GetExtendedPublicKeyCommandResponse, } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; type GetExtendedPublicKeyDARequiredInteraction = | UserInteractionRequired.None @@ -18,14 +19,15 @@ type GetExtendedPublicKeyDARequiredInteraction = export type GetExtendedPublicKeyDAOutput = SendCommandInAppDAOutput; -export type GetExtendedPublicKeyDAError = SendCommandInAppDAError; +export type GetExtendedPublicKeyDAError = + SendCommandInAppDAError; export type GetExtendedDAIntermediateValue = SendCommandInAppDAIntermediateValue; export type GetExtendedPublicKeyDAInput = GetExtendedPublicKeyCommandArgs; -export type GetExtendedPublicKeyReturnType = ExecuteDeviceActionReturnType< +export type GetExtendedPublicKeyDAReturnType = ExecuteDeviceActionReturnType< GetExtendedPublicKeyDAOutput, GetExtendedPublicKeyDAError, GetExtendedDAIntermediateValue diff --git a/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts b/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionTypes.ts similarity index 77% rename from packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts rename to packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionTypes.ts index 289334468..c80327f5f 100644 --- a/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionType.ts +++ b/packages/signer/signer-btc/src/api/app-binder/SignMessageDeviceActionTypes.ts @@ -8,15 +8,20 @@ import { } from "@ledgerhq/device-management-kit"; import { type Signature } from "@api/model/Signature"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; export type SignMessageDAOutput = Signature; export type SignMessageDAInput = { readonly derivationPath: string; readonly message: string; + readonly dataStoreService: DataStoreService; }; -export type SignMessageDAError = OpenAppDAError | CommandErrorResult["error"]; +export type SignMessageDAError = + | OpenAppDAError + | CommandErrorResult["error"]; type SignMessageDARequiredInteraction = | OpenAppDARequiredInteraction diff --git a/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts new file mode 100644 index 000000000..51d62c768 --- /dev/null +++ b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts @@ -0,0 +1,63 @@ +import { + type CommandErrorResult, + type DeviceActionState, + type ExecuteDeviceActionReturnType, + type OpenAppDAError, + type OpenAppDARequiredInteraction, + type UserInteractionRequired, +} from "@ledgerhq/device-management-kit"; + +import { type Psbt } from "@api/model/Psbt"; +import { type PsbtSignature } from "@api/model/Signature"; +import { type Wallet as ApiWallet } from "@api/model/Wallet"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { type BuildPsbtTaskResult } from "@internal/app-binder/task/BuildPsbtTask"; +import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; +import { type PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { type Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; +import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +export type SignPsbtDAOutput = PsbtSignature[]; + +export type SignPsbtDAInput = { + psbt: Psbt; + wallet: ApiWallet; + walletBuilder: WalletBuilder; + walletSerializer: WalletSerializer; + dataStoreService: DataStoreService; + psbtMapper: PsbtMapper; + valueParser: ValueParser; +}; + +export type SignPsbtDAError = + | OpenAppDAError + | CommandErrorResult["error"]; + +type SignPsbtDARequiredInteraction = + | OpenAppDARequiredInteraction + | UserInteractionRequired.SignTransaction; + +export type SignPsbtDAIntermediateValue = { + requiredUserInteraction: SignPsbtDARequiredInteraction; +}; + +export type SignPsbtDAState = DeviceActionState< + SignPsbtDAOutput, + SignPsbtDAError, + SignPsbtDAIntermediateValue +>; + +export type SignPsbtDAInternalState = { + readonly error: SignPsbtDAError | null; + readonly wallet: InternalWallet | null; + readonly buildPsbtResult: BuildPsbtTaskResult | null; + readonly signatures: PsbtSignature[] | null; +}; + +export type SignPsbtDAReturnType = ExecuteDeviceActionReturnType< + SignPsbtDAOutput, + SignPsbtDAError, + SignPsbtDAIntermediateValue +>; diff --git a/packages/signer/signer-btc/src/api/index.ts b/packages/signer/signer-btc/src/api/index.ts index 1122cc65e..a1bdfb302 100644 --- a/packages/signer/signer-btc/src/api/index.ts +++ b/packages/signer/signer-btc/src/api/index.ts @@ -7,7 +7,8 @@ export type { SignMessageDAIntermediateValue, SignMessageDAOutput, SignMessageDAState, -} from "@api/app-binder/SignMessageDeviceActionType"; -export * from "@api/app-binder/SignMessageDeviceActionType"; +} from "@api/app-binder/SignMessageDeviceActionTypes"; +export * from "@api/app-binder/SignPsbtDeviceActionTypes"; +export { DefaultDescriptorTemplate, DefaultWallet } from "@api/model/Wallet"; export * from "@api/SignerBtc"; export * from "@api/SignerBtcBuilder"; diff --git a/packages/signer/signer-btc/src/api/model/Signature.ts b/packages/signer/signer-btc/src/api/model/Signature.ts index 8a7368dd4..4cbe01f5d 100644 --- a/packages/signer/signer-btc/src/api/model/Signature.ts +++ b/packages/signer/signer-btc/src/api/model/Signature.ts @@ -1,3 +1,35 @@ import { type HexaString } from "@ledgerhq/device-management-kit"; export type Signature = { r: HexaString; s: HexaString; v: number }; + +export type PartialSignature = { + inputIndex: number; + pubkey: Uint8Array; + signature: Uint8Array; + tapleafHash?: Uint8Array; +}; + +export type MusigPubNonce = { + inputIndex: number; + participantPubkey: Uint8Array; + aggregatedPubkey: Uint8Array; + tapleafHash: Uint8Array; + pubnonce: Uint8Array; +}; + +export type MusigPartialSignature = { + inputIndex: number; + participantPubkey: Uint8Array; + aggregatedPubkey: Uint8Array; + tapleafHash: Uint8Array; + partialSignature: Uint8Array; +}; + +export type PsbtSignature = + | PartialSignature + | MusigPartialSignature + | MusigPubNonce; + +export const isPartialSignature = ( + psbtSignature: PsbtSignature, +): psbtSignature is PartialSignature => "pubkey" in psbtSignature; diff --git a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts index 203ce128b..8d48d8121 100644 --- a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts +++ b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts @@ -4,11 +4,14 @@ import { } from "@ledgerhq/device-management-kit"; import { type Container } from "inversify"; -import { type SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionType"; +import { type SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; import { type AddressOptions } from "@api/model/AddressOptions"; +import { type Psbt } from "@api/model/Psbt"; +import { type Wallet } from "@api/model/Wallet"; import { type SignerBtc } from "@api/SignerBtc"; import { useCasesTypes } from "@internal/use-cases/di/useCasesTypes"; import { type GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase"; +import { type SignPsbtUseCase } from "@internal/use-cases/sign-psbt/SignPsbtUseCase"; import { type SignMessageUseCase } from "./use-cases/sign-message/SignMessageUseCase"; import { makeContainer } from "./di"; @@ -25,6 +28,12 @@ export class DefaultSignerBtc implements SignerBtc { this._container = makeContainer({ dmk, sessionId }); } + signPsbt(wallet: Wallet, psbt: Psbt) { + return this._container + .get(useCasesTypes.SignPsbtUseCase) + .execute(wallet, psbt); + } + getExtendedPublicKey( derivationPath: string, { checkOnDevice = false }: AddressOptions, diff --git a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts index 64a57a77a..f7ff5ae2b 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.test.ts @@ -21,6 +21,11 @@ import { import { type Signature } from "@api/model/Signature"; import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; +import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; +import { type PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; +import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; describe("BtcAppBinder", () => { const mockedDmk: DeviceManagementKit = { @@ -36,6 +41,11 @@ describe("BtcAppBinder", () => { const binder = new BtcAppBinder( {} as DeviceManagementKit, {} as DeviceSessionId, + {} as WalletBuilder, + {} as WalletSerializer, + {} as DataStoreService, + {} as PsbtMapper, + {} as ValueParser, ); expect(binder).toBeDefined(); }); @@ -66,7 +76,15 @@ describe("BtcAppBinder", () => { }); // WHEN - const appBinder = new BtcAppBinder(mockedDmk, "sessionId"); + const appBinder = new BtcAppBinder( + mockedDmk, + "sessionId", + {} as WalletBuilder, + {} as WalletSerializer, + {} as DataStoreService, + {} as PsbtMapper, + {} as ValueParser, + ); const { observable } = appBinder.getExtendedPublicKey({ derivationPath: "44'/501'", checkOnDevice: false, @@ -116,7 +134,15 @@ describe("BtcAppBinder", () => { }; // WHEN - const appBinder = new BtcAppBinder(mockedDmk, "sessionId"); + const appBinder = new BtcAppBinder( + mockedDmk, + "sessionId", + {} as WalletBuilder, + {} as WalletSerializer, + {} as DataStoreService, + {} as PsbtMapper, + {} as ValueParser, + ); appBinder.getExtendedPublicKey(params); // THEN @@ -141,7 +167,15 @@ describe("BtcAppBinder", () => { }; // WHEN - const appBinder = new BtcAppBinder(mockedDmk, "sessionId"); + const appBinder = new BtcAppBinder( + mockedDmk, + "sessionId", + {} as WalletBuilder, + {} as WalletSerializer, + {} as DataStoreService, + {} as PsbtMapper, + {} as ValueParser, + ); appBinder.getExtendedPublicKey(params); // THEN @@ -184,7 +218,15 @@ describe("BtcAppBinder", () => { }); // WHEN - const appBinder = new BtcAppBinder(mockedDmk, "sessionId"); + const appBinder = new BtcAppBinder( + mockedDmk, + "sessionId", + {} as WalletBuilder, + {} as WalletSerializer, + {} as DataStoreService, + {} as PsbtMapper, + {} as ValueParser, + ); const { observable } = appBinder.signMessage({ derivationPath: "44'/60'/3'/2/1", message, diff --git a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts index 795a1da93..978a50866 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts @@ -8,26 +8,50 @@ import { inject, injectable } from "inversify"; import { GetExtendedPublicKeyDAInput, - GetExtendedPublicKeyReturnType, + GetExtendedPublicKeyDAReturnType, } from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; -import { SignMessageDAReturnType } from "@api/index"; +import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; +import { SignPsbtDAReturnType } from "@api/app-binder/SignPsbtDeviceActionTypes"; +import { Psbt } from "@api/model/Psbt"; +import { Wallet } from "@api/model/Wallet"; import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; +import { SignPsbtDeviceAction } from "@internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction"; +import { dataStoreTypes } from "@internal/data-store/di/dataStoreTypes"; +import type { DataStoreService } from "@internal/data-store/service/DataStoreService"; import { externalTypes } from "@internal/externalTypes"; +import { psbtTypes } from "@internal/psbt/di/psbtTypes"; +import type { PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import type { ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { walletTypes } from "@internal/wallet/di/walletTypes"; +import type { WalletBuilder } from "@internal/wallet/service/WalletBuilder"; +import type { WalletSerializer } from "@internal/wallet/service/WalletSerializer"; import { SignMessageDeviceAction } from "./device-action/SignMessage/SignMessageDeviceAction"; @injectable() export class BtcAppBinder { constructor( - @inject(externalTypes.Dmk) private dmk: DeviceManagementKit, - @inject(externalTypes.SessionId) private sessionId: DeviceSessionId, + @inject(externalTypes.Dmk) + private readonly _dmk: DeviceManagementKit, + @inject(externalTypes.SessionId) + private readonly _sessionId: DeviceSessionId, + @inject(walletTypes.WalletBuilder) + private readonly _walletBuilder: WalletBuilder, + @inject(walletTypes.WalletSerializer) + private readonly _walletSerializer: WalletSerializer, + @inject(dataStoreTypes.DataStoreService) + private readonly _dataStoreService: DataStoreService, + @inject(psbtTypes.PsbtMapper) + private readonly _psbtMapper: PsbtMapper, + @inject(psbtTypes.ValueParser) + private readonly _valueParser: ValueParser, ) {} getExtendedPublicKey( args: GetExtendedPublicKeyDAInput, - ): GetExtendedPublicKeyReturnType { - return this.dmk.executeDeviceAction({ - sessionId: this.sessionId, + ): GetExtendedPublicKeyDAReturnType { + return this._dmk.executeDeviceAction({ + sessionId: this._sessionId, deviceAction: new SendCommandInAppDeviceAction({ input: { command: new GetExtendedPublicKeyCommand(args), @@ -44,12 +68,30 @@ export class BtcAppBinder { derivationPath: string; message: string; }): SignMessageDAReturnType { - return this.dmk.executeDeviceAction({ - sessionId: this.sessionId, + return this._dmk.executeDeviceAction({ + sessionId: this._sessionId, deviceAction: new SignMessageDeviceAction({ input: { derivationPath: args.derivationPath, message: args.message, + dataStoreService: this._dataStoreService, + }, + }), + }); + } + + signPsbt(args: { psbt: Psbt; wallet: Wallet }): SignPsbtDAReturnType { + return this._dmk.executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: new SignPsbtDeviceAction({ + input: { + psbt: args.psbt, + wallet: args.wallet, + walletBuilder: this._walletBuilder, + walletSerializer: this._walletSerializer, + dataStoreService: this._dataStoreService, + psbtMapper: this._psbtMapper, + valueParser: this._valueParser, }, }), }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts index 19ddb619c..9cea0920d 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.test.ts @@ -1,11 +1,9 @@ import { ApduResponse, CommandResultFactory, - GlobalCommandErrorHandler, } from "@ledgerhq/device-management-kit"; import { SW_INTERRUPTED_EXECUTION } from "@internal/app-binder/command/utils/constants"; -import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; import { ContinueCommand } from "./ContinueCommand"; @@ -26,21 +24,10 @@ describe("ContinueCommand", (): void => { 0xef, // Payload data ]); - const parser = (response: ApduResponse) => { - if (BtcCommandUtils.isContinueResponse(response)) { - return CommandResultFactory({ - data: response, - }); - } - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - }; - describe("getApdu", () => { it("should return correct APDU for given payload", () => { // given - const command = new ContinueCommand(defaultArgs, parser); + const command = new ContinueCommand(defaultArgs); // when const apdu = command.getApdu(); // then @@ -51,7 +38,7 @@ describe("ContinueCommand", (): void => { describe("parseResponse", () => { it("should return the APDU response if it's a continue response", () => { // given - const command = new ContinueCommand(defaultArgs, parser); + const command = new ContinueCommand(defaultArgs); const continueResponseData = new Uint8Array([0x01, 0x02, 0x03, 0x04]); const apduResponse = new ApduResponse({ statusCode: SW_INTERRUPTED_EXECUTION, diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts index 5379a9cf4..983548e7f 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/ContinueCommand.ts @@ -4,20 +4,38 @@ import { type ApduResponse, type Command, type CommandResult, + CommandResultFactory, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; + +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export type ContinueCommandArgs = { payload: Uint8Array; }; -export class ContinueCommand - implements Command +export type ContinueCommandResponse = ApduResponse; + +export class ContinueCommand + implements + Command { constructor( - private readonly args: ContinueCommandArgs, - private readonly parseFn: ( - response: ApduResponse, - ) => CommandResult, + private readonly _args: ContinueCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + ContinueCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), ) {} getApdu(): Apdu { @@ -27,11 +45,17 @@ export class ContinueCommand p1: 0x00, p2: 0x00, }) - .addBufferToData(this.args.payload) + .addBufferToData(this._args.payload) .build(); } - parseResponse(response: ApduResponse): CommandResult { - return this.parseFn(response); + parseResponse( + response: ApduResponse, + ): CommandResult { + return Maybe.fromNullable(this._errorHelper.getError(response)).orDefault( + CommandResultFactory({ + data: response, + }), + ); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts index cee126600..44bfda957 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.test.ts @@ -2,10 +2,13 @@ import { ApduResponse, CommandResultFactory, InvalidStatusWordError, - isSuccessCommandResult, - UnknownDeviceExchangeError, } from "@ledgerhq/device-management-kit"; +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; + import { GetExtendedPublicKeyCommand, type GetExtendedPublicKeyCommandArgs, @@ -35,7 +38,7 @@ const GET_EXTENDED_PUBLIC_KEY_VALID_RESPONSE = new Uint8Array([ 0x59, 0x6d, 0x6b, 0x53, 0x48, 0x4c, 0x66, 0x52, 0x31, 0x56, 0x51, 0x59, 0x6a, 0x35, 0x6a, 0x61, 0x79, 0x71, 0x77, 0x53, 0x59, 0x41, 0x52, 0x6e, 0x75, 0x42, 0x4a, 0x69, 0x50, 0x53, 0x44, 0x61, 0x62, 0x79, 0x79, 0x54, 0x69, 0x43, 0x44, - 0x37, 0x42, 0x33, 0x63, 0x6a, 0x50, 0x71, 0x90, 0x00, + 0x37, 0x42, 0x33, 0x63, 0x6a, 0x50, 0x71, ]); describe("GetExtendedPublicKeyCommand", () => { @@ -129,18 +132,21 @@ describe("GetExtendedPublicKeyCommand", () => { const result = command.parseResponse(response); // THEN - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(UnknownDeviceExchangeError); - } else { - fail("Expected an error, but the result was successful"); - } + expect(result).toStrictEqual( + CommandResultFactory({ + error: BtcAppCommandErrorFactory({ + ...BTC_APP_ERRORS["6d00"], + errorCode: "6d00", + }), + }), + ); }); it("should return an error if the response is too short", () => { // GIVEN command = new GetExtendedPublicKeyCommand(defaultArgs); const response = new ApduResponse({ - data: GET_EXTENDED_PUBLIC_KEY_VALID_RESPONSE.slice(0, 2), + data: Uint8Array.from([]), statusCode: new Uint8Array([0x90, 0x00]), }); @@ -148,13 +154,11 @@ describe("GetExtendedPublicKeyCommand", () => { const result = command.parseResponse(response); // THEN - if (!isSuccessCommandResult(result)) { - expect(result.error).toEqual( - new InvalidStatusWordError("Invalid response length"), - ); - } else { - fail("Expected an error, but the result was successful"); - } + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response length"), + }), + ); }); }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts index e119824c7..a1ac455bc 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetExtendedPublicKeyCommand.ts @@ -8,13 +8,20 @@ import { type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; -import { DerivationPathUtils } from "@ledgerhq/signer-utils"; +import { + CommandErrorHelper, + DerivationPathUtils, +} from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; -const STATUS_CODE_LENGTH = 2; +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export type GetExtendedPublicKeyCommandArgs = { checkOnDevice: boolean; @@ -29,13 +36,24 @@ export class GetExtendedPublicKeyCommand implements Command< GetExtendedPublicKeyCommandResponse, - GetExtendedPublicKeyCommandArgs + GetExtendedPublicKeyCommandArgs, + BtcErrorCodes > { - constructor(private readonly args: GetExtendedPublicKeyCommandArgs) {} + constructor( + private readonly _args: GetExtendedPublicKeyCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + GetExtendedPublicKeyCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { - const { checkOnDevice, derivationPath } = this.args; + const { checkOnDevice, derivationPath } = this._args; const getExtendedPublicKeyArgs: ApduBuilderArgs = { cla: 0xe1, @@ -58,31 +76,28 @@ export class GetExtendedPublicKeyCommand parseResponse( response: ApduResponse, - ): CommandResult { - const parser = new ApduParser(response); + ): CommandResult { + return Maybe.fromNullable( + this._errorHelper.getError(response), + ).orDefaultLazy(() => { + const parser = new ApduParser(response); + const length = parser.getUnparsedRemainingLength(); - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } + if (length <= 0) { + return CommandResultFactory({ + error: new InvalidStatusWordError("Invalid response length"), + }); + } - const length = parser.getUnparsedRemainingLength() - STATUS_CODE_LENGTH; + const extendedPublicKey = parser.encodeToString( + parser.extractFieldByLength(length), + ); - if (length <= 0) { return CommandResultFactory({ - error: new InvalidStatusWordError("Invalid response length"), + data: { + extendedPublicKey, + }, }); - } - - const extendedPublicKey = parser.encodeToString( - parser.extractFieldByLength(length), - ); - - return CommandResultFactory({ - data: { - extendedPublicKey, - }, }); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.test.ts index 5b33a4a5f..f30b2c6e1 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.test.ts @@ -47,7 +47,7 @@ describe("GetMasterFingerprintCommand", () => { expect(result).toEqual( CommandResultFactory({ data: { - masterFingerprint: "828dc2f3", + masterFingerprint: Uint8Array.from([0x82, 0x8d, 0xc2, 0xf3]), }, }), ); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.ts index 14796590e..4f9882c01 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetMasterFingerprintCommand.ts @@ -8,20 +8,37 @@ import { type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; + +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; const MASTER_FINGERPRINT_LENGTH = 4; type GetMasterFingerprintCommandResponse = { - masterFingerprint: string; + masterFingerprint: Uint8Array; }; export class GetMasterFingerprintCommand - implements Command + implements Command { + constructor( + private readonly _errorHelper = new CommandErrorHelper< + GetMasterFingerprintCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { const getMasterFingerprintArgs: ApduBuilderArgs = { cla: 0xe1, @@ -34,29 +51,26 @@ export class GetMasterFingerprintCommand parseResponse( response: ApduResponse, - ): CommandResult { - const parser = new ApduParser(response); + ): CommandResult { + return Maybe.fromNullable( + this._errorHelper.getError(response), + ).orDefaultLazy(() => { + const parser = new ApduParser(response); - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } + const masterFingerprint = parser.extractFieldByLength( + MASTER_FINGERPRINT_LENGTH, + ); + if (!masterFingerprint) { + return CommandResultFactory({ + error: new InvalidStatusWordError("Master fingerprint is missing"), + }); + } - if (!parser.testMinimalLength(MASTER_FINGERPRINT_LENGTH)) { return CommandResultFactory({ - error: new InvalidStatusWordError("Master fingerprint is missing"), + data: { + masterFingerprint, + }, }); - } - - const masterFingerprint = parser.encodeToHexaString( - parser.extractFieldByLength(MASTER_FINGERPRINT_LENGTH), - ); - - return CommandResultFactory({ - data: { - masterFingerprint, - }, }); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts index 9f3688102..16e26aeb4 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/GetWalletAddressCommand.test.ts @@ -4,10 +4,7 @@ import { isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; -import { - BitcoinAppCommandError, - bitcoinAppErrors, -} from "./utils/bitcoinAppErrors"; +import { BTC_APP_ERRORS, BtcAppCommandError } from "./utils/bitcoinAppErrors"; import { GetWalletAddressCommand, type GetWalletAddressCommandArgs, @@ -113,10 +110,9 @@ describe("GetWalletAddressCommand", () => { 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(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); 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..e2842b29d 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 @@ -6,17 +6,18 @@ import { type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, InvalidStatusWordError, - isCommandErrorCode, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; import { - BitcoinAppCommandError, - bitcoinAppErrors, + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, } from "./utils/bitcoinAppErrors"; export type GetWalletAddressCommandResponse = { @@ -33,9 +34,23 @@ export type GetWalletAddressCommandArgs = { export class GetWalletAddressCommand implements - Command + Command< + GetWalletAddressCommandResponse, + GetWalletAddressCommandArgs, + BtcErrorCodes + > { - constructor(private readonly args: GetWalletAddressCommandArgs) {} + constructor( + private readonly _args: GetWalletAddressCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + GetWalletAddressCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { return new ApduBuilder({ @@ -44,47 +59,35 @@ export class GetWalletAddressCommand 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) + .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 { - const parser = new ApduParser(response); - const errorCode = parser.encodeToHexaString(response.statusCode); - if (isCommandErrorCode(errorCode, bitcoinAppErrors)) { - return CommandResultFactory({ - error: new BitcoinAppCommandError({ - ...bitcoinAppErrors[errorCode], - errorCode, - }), - }); - } + ): CommandResult { + 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", + ), + }); + } - if (!CommandUtils.isSuccessResponse(response)) { + const address = parser.encodeToString(response.data); return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), + data: { + address, + }, }); - } - - 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, - }, }); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/RegisterWalletAddressCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/RegisterWalletAddressCommand.ts index 22e0edee5..8f169c416 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/RegisterWalletAddressCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/RegisterWalletAddressCommand.ts @@ -5,12 +5,18 @@ import { type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, InvalidStatusWordError, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export type RegisterWalletAddressCommandArgs = { walletPolicy: Uint8Array; @@ -27,10 +33,21 @@ export class RegisterWalletAddressCommand implements Command< RegisterWalletAddressCommandResponse, - RegisterWalletAddressCommandArgs + RegisterWalletAddressCommandArgs, + BtcErrorCodes > { - constructor(private readonly _args: RegisterWalletAddressCommandArgs) {} + constructor( + private readonly _args: RegisterWalletAddressCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + RegisterWalletAddressCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu() { const builder = new ApduBuilder({ @@ -45,26 +62,25 @@ export class RegisterWalletAddressCommand } parseResponse( response: ApduResponse, - ): CommandResult { - const parser = new ApduParser(response); + ): CommandResult { + return Maybe.fromNullable( + this._errorHelper.getError(response), + ).orDefaultLazy(() => { + const parser = new ApduParser(response); - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } - const walletId = parser.extractFieldByLength(RESPONSE_BUFFER_LENGTH); - const walletHmac = parser.extractFieldByLength(RESPONSE_BUFFER_LENGTH); - if (!walletId || !walletHmac) { + const walletId = parser.extractFieldByLength(RESPONSE_BUFFER_LENGTH); + const walletHmac = parser.extractFieldByLength(RESPONSE_BUFFER_LENGTH); + if (!walletId || !walletHmac) { + return CommandResultFactory({ + error: new InvalidStatusWordError("Data mismatch"), + }); + } return CommandResultFactory({ - error: new InvalidStatusWordError("Data mismatch"), + data: { + walletId, + walletHmac, + }, }); - } - return CommandResultFactory({ - data: { - walletId, - walletHmac, - }, }); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.test.ts index b5c97023c..eb6b64c3b 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.test.ts @@ -1,7 +1,6 @@ import { ApduResponse, CommandResultFactory, - InvalidStatusWordError, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; @@ -120,27 +119,6 @@ describe("SignMessageCommand", (): void => { ); }); - it("should return correct response after successful signing", () => { - // given - const command = new SignMessageCommand(defaultArgs); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: getResponse(), - }); - // when - const response = command.parseResponse(apduResponse); - // then - expect(response).toStrictEqual( - CommandResultFactory({ - data: { - v: 27, - r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", - s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", - }, - }), - ); - }); - it("should return an error if user denied the operation", () => { // given const command = new SignMessageCommand(defaultArgs); @@ -157,24 +135,6 @@ describe("SignMessageCommand", (): void => { } }); - it("should return an error when the response data is empty", () => { - // given - const command = new SignMessageCommand(defaultArgs); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: new Uint8Array([]), - }); - // when - const response = command.parseResponse(apduResponse); - // then - expect(isSuccessCommandResult(response)).toBe(false); - expect(response).toStrictEqual( - CommandResultFactory({ - error: new InvalidStatusWordError("V is missing"), - }), - ); - }); - it("should return correct data when the response data is not empty", () => { // given const command = new SignMessageCommand(defaultArgs); @@ -185,46 +145,9 @@ describe("SignMessageCommand", (): void => { // when const response = command.parseResponse(apduResponse); // then - expect(isSuccessCommandResult(response)).toBe(true); - if (isSuccessCommandResult(response)) { - expect(response.data).toStrictEqual({ - v: 27, - r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", - s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", - }); - } - }); - - it("should return an error if 'r' is missing", () => { - // given - const command = new SignMessageCommand(defaultArgs); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: getResponse({ omitR: true }), - }); - // when - const response = command.parseResponse(apduResponse); - // then - expect(response).toStrictEqual( - CommandResultFactory({ - error: new InvalidStatusWordError("R is missing"), - }), - ); - }); - - it("should return an error if 's' is missing", () => { - // given - const command = new SignMessageCommand(defaultArgs); - const apduResponse = new ApduResponse({ - statusCode: new Uint8Array([0x90, 0x00]), - data: getResponse({ omitS: true }), - }); - // when - const response = command.parseResponse(apduResponse); - // then expect(response).toStrictEqual( CommandResultFactory({ - error: new InvalidStatusWordError("S is missing"), + data: apduResponse, }), ); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.ts index 7815112ea..a9cde2064 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/SignMessageCommand.ts @@ -1,31 +1,27 @@ import { type Apdu, ApduBuilder, - ApduParser, type ApduResponse, type Command, type CommandResult, CommandResultFactory, - CommandUtils, - GlobalCommandErrorHandler, - InvalidStatusWordError, - isCommandErrorCode, } from "@ledgerhq/device-management-kit"; -import { DerivationPathUtils } from "@ledgerhq/signer-utils"; +import { + CommandErrorHelper, + DerivationPathUtils, +} from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; -import { type Signature } from "@api/model/Signature"; import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants"; -import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; import { encodeVarint } from "@internal/utils/Varint"; import { - BitcoinAppCommandError, - bitcoinAppErrors, + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, } from "./utils/bitcoinAppErrors"; -const R_LENGTH = 32; -const S_LENGTH = 32; - export type SignMessageCommandArgs = { /** * The BIP32 path (e.g., "m/44'/0'/0'/0/0") @@ -41,19 +37,26 @@ export type SignMessageCommandArgs = { readonly messageMerkleRoot: Uint8Array; }; -export type SignMessageCommandResponse = Signature | ApduResponse; +export type SignMessageCommandResponse = ApduResponse; export class SignMessageCommand - implements Command + implements + Command { - readonly args: SignMessageCommandArgs; - - constructor(args: SignMessageCommandArgs) { - this.args = args; - } + constructor( + private readonly _args: SignMessageCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + SignMessageCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { - const { derivationPath, messageLength, messageMerkleRoot } = this.args; + const { derivationPath, messageLength, messageMerkleRoot } = this._args; const builder = new ApduBuilder({ cla: 0xe1, @@ -76,66 +79,9 @@ export class SignMessageCommand parseResponse( apduResponse: ApduResponse, - ): CommandResult { - if (BtcCommandUtils.isContinueResponse(apduResponse)) { - return CommandResultFactory({ - data: apduResponse, - }); - } - - if (!CommandUtils.isSuccessResponse(apduResponse)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(apduResponse), - }); - } - - const parser = new ApduParser(apduResponse); - const errorCode = parser.encodeToHexaString(apduResponse.statusCode); - if (isCommandErrorCode(errorCode, bitcoinAppErrors)) { - return CommandResultFactory({ - error: new BitcoinAppCommandError({ - ...bitcoinAppErrors[errorCode], - errorCode, - }), - }); - } - - // Extract 'v' - const v = parser.extract8BitUInt(); - if (v === undefined) { - return CommandResultFactory({ - error: new InvalidStatusWordError("V is missing"), - }); - } - - // Extract 'r' - const r = parser.encodeToHexaString( - parser.extractFieldByLength(R_LENGTH), - true, - ); - if (!r) { - return CommandResultFactory({ - error: new InvalidStatusWordError("R is missing"), - }); - } - - // Extract 's' - const s = parser.encodeToHexaString( - parser.extractFieldByLength(S_LENGTH), - true, - ); - if (!s) { - return CommandResultFactory({ - error: new InvalidStatusWordError("S is missing"), - }); - } - - return CommandResultFactory({ - data: { - v, - r, - s, - }, - }); + ): CommandResult { + return Maybe.fromNullable( + this._errorHelper.getError(apduResponse), + ).orDefault(CommandResultFactory({ data: apduResponse })); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.test.ts index 4299972be..dbc5dc74a 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.test.ts @@ -8,7 +8,7 @@ import { type SignPsbtCommandArgs, } from "@internal/app-binder/command/SignPsbtCommand"; -const GLOBAL_COMMITMENTS = Uint8Array.from([ +const GLOBAL_COMMITMENT = Uint8Array.from([ 0x05, 0x51, 0x9b, 0x38, 0xda, 0xe7, 0x44, 0x47, 0xb7, 0x21, 0x51, 0xf3, 0x54, 0xcb, 0x13, 0x8c, 0xa3, 0x59, 0x1a, 0x5f, 0xf8, 0xac, 0x81, 0x32, 0x89, 0xb1, 0x8a, 0x00, 0x4e, 0x31, 0x32, 0x16, 0x20, 0x3a, 0x22, 0x1f, 0x4b, 0xb9, 0x5e, @@ -16,13 +16,13 @@ const GLOBAL_COMMITMENTS = Uint8Array.from([ 0xa3, 0x43, 0x51, 0x65, 0xd3, 0xdf, 0xb7, 0x35, 0xce, 0x2d, 0xf5, 0xf5, 0x8f, ]); -const INPUTS_COMMITMENTS = Uint8Array.from([ +const INPUTS_ROOT = Uint8Array.from([ 0x01, 0x2a, 0xc8, 0xcd, 0xbc, 0x6f, 0xd6, 0x43, 0x70, 0x05, 0x56, 0x63, 0xf9, 0x50, 0x2f, 0xe3, 0x66, 0xed, 0xf8, 0x49, 0x70, 0xcc, 0x7d, 0x7e, 0xe8, 0xf6, 0xba, 0x47, 0x59, 0x9f, 0x11, 0x05, 0xc2, ]); -const OUTPUTS_COMMITMENTS = Uint8Array.from([ +const OUTPUTS_ROOT = Uint8Array.from([ 0x01, 0xd9, 0x35, 0x14, 0xd4, 0x29, 0x68, 0x8d, 0x76, 0x57, 0xc9, 0xaf, 0x0a, 0x08, 0x86, 0xac, 0x74, 0x4b, 0xd0, 0x88, 0x1c, 0x4a, 0x19, 0x10, 0xb5, 0x37, 0xfa, 0xba, 0x28, 0xcd, 0xca, 0x2e, 0x11, @@ -42,19 +42,23 @@ const SIGN_PSBT_APDU = Uint8Array.from([ 0x04, 0x00, 0x01, - 0xc3, - ...GLOBAL_COMMITMENTS, - ...INPUTS_COMMITMENTS, - ...OUTPUTS_COMMITMENTS, + 0xc5, + ...GLOBAL_COMMITMENT, + 0x01, + ...INPUTS_ROOT, + 0x01, + ...OUTPUTS_ROOT, ...WALLET_ID, ...WALLET_HMAC, ]); describe("SignPsbtCommand", () => { const args: SignPsbtCommandArgs = { - globalCommitments: GLOBAL_COMMITMENTS, - inputsCommitments: INPUTS_COMMITMENTS, - outputsCommitments: OUTPUTS_COMMITMENTS, + globalCommitment: GLOBAL_COMMITMENT, + inputsCount: 1, + inputsRoot: INPUTS_ROOT, + outputsCount: 1, + outputsRoot: OUTPUTS_ROOT, walletId: WALLET_ID, walletHmac: WALLET_HMAC, }; diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts index 95ea0873a..f63d970f5 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/SignPsbtCommand.ts @@ -5,26 +5,45 @@ import { type Command, type CommandResult, CommandResultFactory, - GlobalCommandErrorHandler, } from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; +import { + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + type BtcErrorCodes, +} from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { PROTOCOL_VERSION } from "@internal/app-binder/command/utils/constants"; -import { CommandUtils } from "@internal/utils/CommandUtils"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export type SignPsbtCommandArgs = { - globalCommitments: Uint8Array; - inputsCommitments: Uint8Array; - outputsCommitments: Uint8Array; + globalCommitment: Uint8Array; + inputsCount: number; + inputsRoot: Uint8Array; + outputsCount: number; + outputsRoot: Uint8Array; walletId: Uint8Array; walletHmac: Uint8Array; }; -type SignPsbtCommandResponse = void; +type SignPsbtCommandResponse = ApduResponse; export class SignPsbtCommand - implements Command + implements + Command { - constructor(private _args: SignPsbtCommandArgs) {} + constructor( + private readonly _args: SignPsbtCommandArgs, + private readonly _errorHelper = new CommandErrorHelper< + SignPsbtCommandResponse, + BtcErrorCodes + >( + BTC_APP_ERRORS, + BtcAppCommandErrorFactory, + BtcCommandUtils.isSuccessResponse, + ), + ) {} getApdu(): Apdu { const builder = new ApduBuilder({ @@ -34,31 +53,30 @@ export class SignPsbtCommand p2: PROTOCOL_VERSION, }); const { - globalCommitments, - inputsCommitments, - outputsCommitments, + globalCommitment, + inputsCount, + inputsRoot, + outputsCount, + outputsRoot, walletHmac, walletId, } = this._args; return builder - .addBufferToData(globalCommitments) - .addBufferToData(inputsCommitments) - .addBufferToData(outputsCommitments) + .addBufferToData(globalCommitment) + .add8BitUIntToData(inputsCount) + .addBufferToData(inputsRoot) + .add8BitUIntToData(outputsCount) + .addBufferToData(outputsRoot) .addBufferToData(walletId) .addBufferToData(walletHmac) .build(); } parseResponse( response: ApduResponse, - ): CommandResult { - if (!CommandUtils.isSuccessResponse(response)) { - return CommandResultFactory({ - error: GlobalCommandErrorHandler.handle(response), - }); - } - return CommandResultFactory({ - data: undefined, - }); + ): CommandResult { + return Maybe.fromNullable(this._errorHelper.getError(response)).orDefault( + CommandResultFactory({ data: response }), + ); } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts index 08c392fad..12a882aa2 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppError.test.ts @@ -1,12 +1,12 @@ import { DeviceExchangeError } from "@ledgerhq/device-management-kit"; import { - BitcoinAppCommandError, - type BitcoinAppErrorCodes, - bitcoinAppErrors, + BTC_APP_ERRORS, + BtcAppCommandError, + type BtcErrorCodes, } from "./bitcoinAppErrors"; -describe("BitcoinAppCommandError", () => { +describe("BtcAppCommandError", () => { afterEach(() => { jest.resetAllMocks(); }); @@ -16,7 +16,7 @@ describe("BitcoinAppCommandError", () => { }); it("should be an instance of DeviceExchangeError", () => { - const error = new BitcoinAppCommandError({ + const error = new BtcAppCommandError({ message: "Test error message", errorCode: "6985", }); @@ -26,7 +26,7 @@ describe("BitcoinAppCommandError", () => { it("should set the correct message when provided", () => { const customMessage = "Custom error message"; - const error = new BitcoinAppCommandError({ + const error = new BtcAppCommandError({ message: customMessage, errorCode: "6985", }); @@ -34,35 +34,26 @@ describe("BitcoinAppCommandError", () => { 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({ + const errorCode: BtcErrorCodes = "6a86"; + const error = new BtcAppCommandError({ message: "Either P1 or P2 is incorrect", errorCode, }); - expect(error.customErrorCode).toBe(errorCode); + expect(error.errorCode).toBe(errorCode); }); it("should correlate error codes with messages from bitcoinAppErrors", () => { - const errorCode: BitcoinAppErrorCodes = "6E00"; - const expectedMessage = bitcoinAppErrors[errorCode].message; + const errorCode: BtcErrorCodes = "6e00"; + const expectedMessage = BTC_APP_ERRORS[errorCode].message; - const error = new BitcoinAppCommandError({ + const error = new BtcAppCommandError({ message: expectedMessage, errorCode, }); - expect(error.customErrorCode).toBe(errorCode); + expect(error.errorCode).toBe(errorCode); expect(error.message).toBe(expectedMessage); expect(error).toBeInstanceOf(DeviceExchangeError); diff --git a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts index 3fd64003f..9561ff9d8 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/command/utils/bitcoinAppErrors.ts @@ -1,44 +1,40 @@ -//temp file, will be changed in a specific PR - import { + type CommandErrorArgs, type CommandErrors, DeviceExchangeError, - type DmkError, } from "@ledgerhq/device-management-kit"; -export type BitcoinAppErrorCodes = +export type BtcErrorCodes = + | "6a80" + | "6a82" | "6985" - | "6A86" - | "6A87" - | "6D00" - | "6E00" - | "B000" - | "B007" - | "B008"; + | "6a86" + | "6a87" + | "6d00" + | "6e00" + | "b000" + | "b007" + | "b008"; -export const bitcoinAppErrors: CommandErrors = { +export const BTC_APP_ERRORS: CommandErrors = { + "6a80": { message: "Incorrect data" }, + "6a82": { message: "Request not supported" }, "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" }, + "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 - 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; +export class BtcAppCommandError extends DeviceExchangeError { + constructor(args: CommandErrorArgs) { + super({ tag: "BtcAppCommandError", ...args }); } } + +export const BtcAppCommandErrorFactory = ( + args: CommandErrorArgs, +) => new BtcAppCommandError(args); diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts index d2b50da47..cf3c24c6a 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts @@ -11,6 +11,7 @@ import { type SignMessageDAState } from "@api/index"; import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; import { setupOpenAppDAMock } from "@internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock"; import { testDeviceActionStates } from "@internal/app-binder/device-action/__test-utils__/testDeviceActionStates"; +import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; import { SignMessageDeviceAction } from "./SignMessageDeviceAction"; @@ -34,7 +35,7 @@ describe("SignMessageDeviceAction", () => { }; } - beforeEach(() => { + afterEach(() => { jest.resetAllMocks(); }); @@ -46,6 +47,7 @@ describe("SignMessageDeviceAction", () => { input: { derivationPath: "44'/60'/0'/0/0", message: "Hello world", + dataStoreService: "DataStoreService" as unknown as DataStoreService, }, }); @@ -102,7 +104,7 @@ describe("SignMessageDeviceAction", () => { done, ); - // Verify mocks calls parameters + // @todo Put this in a onDone handle of testDeviceActionStates observable.subscribe({ complete: () => { expect(signPersonalMessageMock).toHaveBeenCalledWith( @@ -110,6 +112,7 @@ describe("SignMessageDeviceAction", () => { input: { derivationPath: "44'/60'/0'/0/0", message: "Hello world", + dataStoreService: "DataStoreService", }, }), ); @@ -145,6 +148,7 @@ describe("SignMessageDeviceAction", () => { input: { derivationPath: "44'/60'/0'/0/0", message: "Hello world", + dataStoreService: "DataStoreService" as unknown as DataStoreService, }, }); @@ -163,6 +167,7 @@ describe("SignMessageDeviceAction", () => { input: { derivationPath: "44'/60'/0'/0/0", message: "Hello world", + dataStoreService: "DataStoreService" as unknown as DataStoreService, }, }); @@ -217,6 +222,7 @@ describe("SignMessageDeviceAction", () => { input: { derivationPath: "44'/60'/0'/0/0", message: "Hello world", + dataStoreService: "DataStoreService" as unknown as DataStoreService, }, }); @@ -269,6 +275,7 @@ describe("SignMessageDeviceAction", () => { input: { derivationPath: "44'/60'/0'/0/0", message: "Hello world", + dataStoreService: "DataStoreService" as unknown as DataStoreService, }, }); @@ -323,6 +330,7 @@ describe("SignMessageDeviceAction", () => { input: { derivationPath: "44'/60'/0'/0/0", message: "Hello world", + dataStoreService: "DataStoreService" as unknown as DataStoreService, }, }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts index 9a5060101..6e57795d3 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.ts @@ -18,17 +18,19 @@ import { type SignMessageDAIntermediateValue, type SignMessageDAInternalState, type SignMessageDAOutput, -} from "@api/app-binder/SignMessageDeviceActionType"; +} from "@api/app-binder/SignMessageDeviceActionTypes"; import { type Signature } from "@api/model/Signature"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { SendSignMessageTask, type SendSignMessageTaskArgs, } from "@internal/app-binder/task/SignMessageTask"; +import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; export type MachineDependencies = { readonly signMessage: (arg0: { - input: SendSignMessageTaskArgs; - }) => Promise>; + input: SendSignMessageTaskArgs & { dataStoreService: DataStoreService }; + }) => Promise>; }; export type ExtractMachineDependencies = ( @@ -160,6 +162,7 @@ export class SignMessageDeviceAction extends XStateDeviceAction< input: ({ context }) => ({ derivationPath: context.input.derivationPath, message: context.input.message, + dataStoreService: context.input.dataStoreService, }), onDone: { target: "SignMessageResultCheck", @@ -214,8 +217,18 @@ export class SignMessageDeviceAction extends XStateDeviceAction< input: { derivationPath: string; message: string; + dataStoreService: DataStoreService; }; - }) => new SendSignMessageTask(internalApi, arg0.input).run(); + }) => { + const { + input: { derivationPath, message, dataStoreService }, + } = arg0; + return new SendSignMessageTask( + internalApi, + { derivationPath, message }, + dataStoreService, + ).run(); + }; return { signMessage, diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts new file mode 100644 index 000000000..528f17fd5 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts @@ -0,0 +1,583 @@ +import { + CommandResultFactory, + DeviceActionStatus, + UnknownDeviceExchangeError, + UserInteractionRequired, +} from "@ledgerhq/device-management-kit"; +import { UnknownDAError } from "@ledgerhq/device-management-kit"; +import { InvalidStatusWordError } from "@ledgerhq/device-management-kit"; + +import { type SignPsbtDAState } from "@api/app-binder/SignPsbtDeviceActionTypes"; +import { type RegisteredWallet } from "@api/model/Wallet"; +import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; +import { setupOpenAppDAMock } from "@internal/app-binder/device-action/__test-utils__/setupOpenAppDAMock"; +import { testDeviceActionStates } from "@internal/app-binder/device-action/__test-utils__/testDeviceActionStates"; +import { type BuildPsbtTaskResult } from "@internal/app-binder/task/BuildPsbtTask"; +import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; +import { type PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { type Wallet } from "@internal/wallet/model/Wallet"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; +import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +import { SignPsbtDeviceAction } from "./SignPsbtDeviceAction"; + +jest.mock( + "@ledgerhq/device-management-kit", + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + () => ({ + ...jest.requireActual("@ledgerhq/device-management-kit"), + OpenAppDeviceAction: jest.fn(() => ({ + makeStateMachine: jest.fn(), + })), + }), +); + +describe("SignPsbtDeviceAction", () => { + const signPsbtMock = jest.fn(); + const prepareWalletPolicyMock = jest.fn(); + const buildPsbtMock = jest.fn(); + + function extractDependenciesMock() { + return { + signPsbt: signPsbtMock, + prepareWalletPolicy: prepareWalletPolicyMock, + buildPsbt: buildPsbtMock, + }; + } + + describe("Success case", () => { + it("should call external dependencies with the correct parameters", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: "ApiWallet" as unknown as RegisteredWallet, + psbt: "Hello world", + walletBuilder: "WalletBuilder" as unknown as WalletBuilder, + walletSerializer: "WalletSerializer" as unknown as WalletSerializer, + dataStoreService: "DataStoreService" as unknown as DataStoreService, + psbtMapper: "PsbtMapper" as unknown as PsbtMapper, + valueParser: "ValueParser" as unknown as ValueParser, + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + prepareWalletPolicyMock.mockResolvedValueOnce( + CommandResultFactory({ + data: "Wallet" as unknown as Wallet, + }), + ); + buildPsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + data: "BuildPsbtResult" as unknown as BuildPsbtTaskResult, + }), + ); + signPsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + data: [ + { + inputIndex: 0, + pubkey: Uint8Array.from([0x04, 0x05, 0x06]), + signature: Uint8Array.from([0x01, 0x02, 0x03]), + }, + ], + }), + ); + + // Expected intermediate values for the following state sequence: + // Initial -> OpenApp -> PrepareWalletPolicy -> BuildPsbt -> SignPsbt + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + status: DeviceActionStatus.Pending, + }, + { + output: [ + { + inputIndex: 0, + pubkey: Uint8Array.from([0x04, 0x05, 0x06]), + signature: Uint8Array.from([0x01, 0x02, 0x03]), + }, + ], + status: DeviceActionStatus.Completed, + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + + // @todo Put this in a onDone handle of testDeviceActionStates + observable.subscribe({ + complete: () => { + expect(prepareWalletPolicyMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { wallet: "ApiWallet", walletBuilder: "WalletBuilder" }, + }), + ); + expect(buildPsbtMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + psbt: "Hello world", + wallet: "Wallet", + dataStoreService: "DataStoreService", + psbtMapper: "PsbtMapper", + }, + }), + ); + expect(signPsbtMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + wallet: "Wallet", + buildPsbtResult: "BuildPsbtResult", + walletSerializer: "WalletSerializer", + valueParser: "ValueParser", + }, + }), + ); + }, + }); + }); + }); + + describe("error cases", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it("Error if open app fails", (done) => { + setupOpenAppDAMock(new UnknownDeviceExchangeError("Mocked error")); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + walletBuilder: {} as WalletBuilder, + walletSerializer: {} as WalletSerializer, + dataStoreService: {} as DataStoreService, + psbtMapper: {} as PsbtMapper, + valueParser: {} as ValueParser, + }, + }); + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if prepareWallet fails", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + walletBuilder: {} as WalletBuilder, + walletSerializer: {} as WalletSerializer, + dataStoreService: {} as DataStoreService, + psbtMapper: {} as PsbtMapper, + valueParser: {} as ValueParser, + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + prepareWalletPolicyMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Mocked error"), + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if buildPsbt fails", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + walletBuilder: {} as WalletBuilder, + walletSerializer: {} as WalletSerializer, + dataStoreService: {} as DataStoreService, + psbtMapper: {} as PsbtMapper, + valueParser: {} as ValueParser, + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + prepareWalletPolicyMock.mockResolvedValueOnce( + CommandResultFactory({ + data: {} as Wallet, + }), + ); + buildPsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Mocked error"), + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if signPsbt fails", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + walletBuilder: {} as WalletBuilder, + walletSerializer: {} as WalletSerializer, + dataStoreService: {} as DataStoreService, + psbtMapper: {} as PsbtMapper, + valueParser: {} as ValueParser, + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + prepareWalletPolicyMock.mockResolvedValueOnce( + CommandResultFactory({ + data: {} as Wallet, + }), + ); + buildPsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + data: {} as BuildPsbtTaskResult, + }), + ); + signPsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Mocked error"), + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if signPsbt throws an exception", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + walletBuilder: {} as WalletBuilder, + walletSerializer: {} as WalletSerializer, + dataStoreService: {} as DataStoreService, + psbtMapper: {} as PsbtMapper, + valueParser: {} as ValueParser, + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + prepareWalletPolicyMock.mockResolvedValueOnce( + CommandResultFactory({ data: {} as Wallet }), + ); + buildPsbtMock.mockResolvedValueOnce( + CommandResultFactory({ data: {} as BuildPsbtTaskResult }), + ); + signPsbtMock.mockRejectedValueOnce( + new InvalidStatusWordError("Mocked error"), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + }, + { + status: DeviceActionStatus.Error, + error: new InvalidStatusWordError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Return a Left if the final state has no signature", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + walletBuilder: {} as WalletBuilder, + walletSerializer: {} as WalletSerializer, + dataStoreService: {} as DataStoreService, + psbtMapper: {} as PsbtMapper, + valueParser: {} as ValueParser, + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + prepareWalletPolicyMock.mockResolvedValueOnce( + CommandResultFactory({ + data: {} as Wallet, + }), + ); + buildPsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + data: {} as BuildPsbtTaskResult, + }), + ); + signPsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + data: undefined, + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDAError("No error in final state"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts new file mode 100644 index 000000000..7d0aa66bf --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts @@ -0,0 +1,389 @@ +import { + type CommandResult, + type DeviceActionStateMachine, + type InternalApi, + isSuccessCommandResult, + OpenAppDeviceAction, + type StateMachineTypes, + UnknownDAError, + UserInteractionRequired, + XStateDeviceAction, +} from "@ledgerhq/device-management-kit"; +import { Left, Right } from "purify-ts"; +import { assign, fromPromise, setup } from "xstate"; + +import { + type SignPsbtDAError, + type SignPsbtDAInput, + type SignPsbtDAIntermediateValue, + type SignPsbtDAInternalState, + type SignPsbtDAOutput, +} from "@api/app-binder/SignPsbtDeviceActionTypes"; +import { type Psbt as ApiPsbt } from "@api/model/Psbt"; +import { type PsbtSignature } from "@api/model/Signature"; +import { type Wallet as ApiWallet } from "@api/model/Wallet"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { + BuildPsbtTask, + type BuildPsbtTaskResult, +} from "@internal/app-binder/task/BuildPsbtTask"; +import { PrepareWalletPolicyTask } from "@internal/app-binder/task/PrepareWalletPolicyTask"; +import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; +import type { DataStoreService } from "@internal/data-store/service/DataStoreService"; +import type { PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import type { ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { type Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; +import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +export type MachineDependencies = { + readonly prepareWalletPolicy: (arg0: { + input: { + wallet: ApiWallet; + walletBuilder: WalletBuilder; + }; + }) => Promise>; + readonly buildPsbt: (arg0: { + input: { + psbt: ApiPsbt; + wallet: InternalWallet; + dataStoreService: DataStoreService; + psbtMapper: PsbtMapper; + }; + }) => Promise>; + readonly signPsbt: (arg0: { + input: { + wallet: InternalWallet; + buildPsbtResult: BuildPsbtTaskResult; + walletSerializer: WalletSerializer; + valueParser: ValueParser; + }; + }) => Promise>; +}; + +export type ExtractMachineDependencies = ( + internalApi: InternalApi, +) => MachineDependencies; + +export class SignPsbtDeviceAction extends XStateDeviceAction< + SignPsbtDAOutput, + SignPsbtDAInput, + SignPsbtDAError, + SignPsbtDAIntermediateValue, + SignPsbtDAInternalState +> { + constructor(args: { input: SignPsbtDAInput; inspect?: boolean }) { + super(args); + } + makeStateMachine( + internalApi: InternalApi, + ): DeviceActionStateMachine< + SignPsbtDAOutput, + SignPsbtDAInput, + SignPsbtDAError, + SignPsbtDAIntermediateValue, + SignPsbtDAInternalState + > { + type types = StateMachineTypes< + SignPsbtDAOutput, + SignPsbtDAInput, + SignPsbtDAError, + SignPsbtDAIntermediateValue, + SignPsbtDAInternalState + >; + + const { signPsbt, prepareWalletPolicy, buildPsbt } = + this.extractDependencies(internalApi); + + return setup({ + types: { + input: {} as types["input"], + context: {} as types["context"], + output: {} as types["output"], + }, + + actors: { + openAppStateMachine: new OpenAppDeviceAction({ + input: { appName: "Bitcoin" }, + }).makeStateMachine(internalApi), + prepareWalletPolicy: fromPromise(prepareWalletPolicy), + buildPsbt: fromPromise(buildPsbt), + signPsbt: fromPromise(signPsbt), + }, + guards: { + noInternalError: ({ context }) => context._internalState.error === null, + }, + actions: { + assignErrorFromEvent: assign({ + _internalState: (_) => ({ + ..._.context._internalState, + error: _.event["error"], // NOTE: it should never happen, the error is not typed anymore here + }), + }), + }, + }).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QGUCWUB2AFMAnWA9hgIYA2AsnLMTACJgBuqAxmAILMAuqRAdAPIAHMBjaDB9Jqw7ciAYghEwvVBgYEA1soLDR45J2Kcw5YswAWqsAG0ADAF1EoQQVipZGJyAAeiACwAzADsvACcABwAjKGRtuG2AGyRSX4ANCAAnoiRAEwArLy2uX6RQUGRpeHhfgC+NelomDj4RGSUsNR0jCzsXDwYvADC5mDMGkIiYhLd0n1EAEpwAK6knHJ2jkggLm4eXr4IoRG8eTlFOaWhQX45QaHpWQiRN4UBfu8JeaE3obY5OXUGuhsHhCCQKFQaGBJD0ZP0hiMxhM9NMpL0PItYCs1tZIptnK53P19ogjuETmdcpdrrd7pl-M8wnkgvkgvFbH48n4EkFASBGiCWuD2p1oTN0fCBc0wW1ITAFEoVGpNMo3E1Qa0IR0oRsvDsiUQSU88tVeOEktEEuE8m8cjcHqScicgokTXk8rZygFQgD6vzgdLNSKoTDZh5eFKNcK5WA5HhcARcLxBKQjAAzRMAW14asFMq1ot1W31ey2B0iJr8ZotoStNpu9vpCDtoTNHr8PoizyKvL9kaFsu1XTRcL4-fzwZgmOxw1GGnWDj1hNLoAOFwCBWC5u5RySEQdT1sra+OSt1wCAQuzICfPHQZjoYlY4DUcHounq1nY3WeKXu2JZaIOum5sgkO61tE4QHhWBS2HkCT-G8R5wc8N58hgBAQHAXh3tGQ5iiOcyeMWy4AauiAALQJAeVGFLY9EMYxDG9kC6oDgWIbiqOAzIlMj7cX+BrEeRCCNo8sS2CcRz-B6zIsnBaGsXm974fxREInOvHiGpGLLKsgkrj4iBnrwFwVgkCHfEeCQBNB3K8IEpxBO6ySep8in+mxE4Plx6m4W+UIGWRRlPJEVRmncOTmhyLI2QeUS8GFQRvPBXZRLWt4vuxk4EbCflZd5+EfpwX4aEFhqAU84RRYUpyXmBATJBeQTQZEARhKEG5Ne69GUplXkqaKOmSkszCsB05XCSFOSXgUyVshUdoJLYF7QYkvAXkUER3H45q1qE-XKXhQ2+eGACiuAJrgk1GjN+S8PNUTFMtq1Nrckl-O6wTct6KF5HUdRAA */ + id: "SignPsbtDeviceAction", + initial: "OpenAppDeviceAction", + context: ({ input }) => { + return { + input, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + _internalState: { + error: null, + wallet: null, + buildPsbtResult: null, + signatures: null, + signedPsbt: null, + }, + }; + }, + states: { + OpenAppDeviceAction: { + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "openAppStateMachine", + input: { appName: "Bitcoin" }, + src: "openAppStateMachine", + onSnapshot: { + actions: assign({ + intermediateValue: (_) => + _.event.snapshot.context.intermediateValue, + }), + }, + onDone: { + actions: assign({ + _internalState: (_) => { + return _.event.output.caseOf({ + Right: () => _.context._internalState, + Left: (error) => ({ + ..._.context._internalState, + error, + }), + }); + }, + }), + target: "CheckOpenAppDeviceActionResult", + }, + }, + }, + CheckOpenAppDeviceActionResult: { + always: [ + { + target: "PrepareWalletPolicy", + guard: "noInternalError", + }, + "Error", + ], + }, + PrepareWalletPolicy: { + invoke: { + id: "prepareWalletPolicy", + src: "prepareWalletPolicy", + input: ({ context }) => ({ + wallet: context.input.wallet, + walletBuilder: context.input.walletBuilder, + }), + onDone: { + target: "PrepareWalletPolicyResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + wallet: event.output.data, + }; + } + return { + ...context._internalState, + error: event.output.error, + }; + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + PrepareWalletPolicyResultCheck: { + always: [ + { guard: "noInternalError", target: "BuildPsbt" }, + { target: "Error" }, + ], + }, + BuildPsbt: { + invoke: { + id: "buildPsbt", + src: "buildPsbt", + input: ({ context }) => ({ + psbt: context.input.psbt, + wallet: context._internalState.wallet!, + dataStoreService: context.input.dataStoreService, + psbtMapper: context.input.psbtMapper, + }), + onDone: { + target: "BuildPsbtResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + buildPsbtResult: event.output.data, + }; + } + return { + ...context._internalState, + error: event.output.error, + }; + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + BuildPsbtResultCheck: { + always: [ + { guard: "noInternalError", target: "SignPsbt" }, + { target: "Error" }, + ], + }, + SignPsbt: { + entry: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + }), + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "signPsbt", + src: "signPsbt", + input: ({ context }) => ({ + walletSerializer: context.input.walletSerializer, + valueParser: context.input.valueParser, + buildPsbtResult: context._internalState.buildPsbtResult!, + wallet: context._internalState.wallet!, + }), + onDone: { + target: "SignPsbtResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + signatures: event.output.data, + }; + } + return { + ...context._internalState, + error: event.output.error, + }; + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + SignPsbtResultCheck: { + always: [ + { guard: "noInternalError", target: "Success" }, + { target: "Error" }, + ], + }, + Success: { + type: "final", + }, + Error: { + type: "final", + }, + }, + output: ({ + context: { + _internalState: { signatures, error }, + }, + }) => + signatures + ? Right(signatures) + : Left(error || new UnknownDAError("No error in final state")), + }); + } + + extractDependencies(internalApi: InternalApi): MachineDependencies { + const prepareWalletPolicy = async (arg0: { + input: { wallet: ApiWallet; walletBuilder: WalletBuilder }; + }): Promise> => { + const { + input: { walletBuilder, wallet }, + } = arg0; + return await new PrepareWalletPolicyTask( + internalApi, + { wallet }, + walletBuilder, + ).run(); + }; + const buildPsbt = async (arg0: { + input: { + psbt: ApiPsbt; + wallet: InternalWallet; + dataStoreService: DataStoreService; + psbtMapper: PsbtMapper; + }; + }): Promise> => { + const { + input: { psbt, wallet, dataStoreService, psbtMapper }, + } = arg0; + return new BuildPsbtTask( + { psbt, wallet }, + dataStoreService, + psbtMapper, + ).run(); + }; + const signPsbt = async (arg0: { + input: { + wallet: InternalWallet; + buildPsbtResult: BuildPsbtTaskResult; + walletSerializer: WalletSerializer; + valueParser: ValueParser; + }; + }): Promise> => { + const { + input: { wallet, buildPsbtResult, walletSerializer, valueParser }, + } = arg0; + return await new SignPsbtTask( + internalApi, + { wallet, ...buildPsbtResult }, + walletSerializer, + valueParser, + ).run(); + }; + + return { + prepareWalletPolicy, + buildPsbt, + signPsbt, + }; + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/di/appBinderModule.ts b/packages/signer/signer-btc/src/internal/app-binder/di/appBinderModule.ts index c2aef75e9..37ef6d120 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/di/appBinderModule.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/di/appBinderModule.ts @@ -2,6 +2,7 @@ import { ContainerModule } from "inversify"; import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; +import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; export const appBinderModuleFactory = () => new ContainerModule( @@ -15,5 +16,6 @@ export const appBinderModuleFactory = () => _onDeactivation, ) => { bind(appBinderTypes.AppBinder).to(BtcAppBinder); + bind(appBinderTypes.SignPsbtTask).to(SignPsbtTask); }, ); diff --git a/packages/signer/signer-btc/src/internal/app-binder/di/appBinderTypes.ts b/packages/signer/signer-btc/src/internal/app-binder/di/appBinderTypes.ts index 732bef938..b45fb7eba 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/di/appBinderTypes.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/di/appBinderTypes.ts @@ -1,3 +1,4 @@ export const appBinderTypes = { AppBinder: Symbol.for("AppBinder"), + SignPsbtTask: Symbol.for("SignPsbtTask"), }; diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.test.ts new file mode 100644 index 000000000..c6d0f377c --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.test.ts @@ -0,0 +1,117 @@ +import { + CommandResultFactory, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; +import { Left, Right } from "purify-ts"; + +import { type Psbt } from "@api/model/Psbt"; +import { BuildPsbtTask } from "@internal/app-binder/task/BuildPsbtTask"; +import { DataStore } from "@internal/data-store/model/DataStore"; +import { + type DataStoreService, + type PsbtCommitment, +} from "@internal/data-store/service/DataStoreService"; +import { type Psbt as InternalPsbt } from "@internal/psbt/model/Psbt"; +import { type Wallet } from "@internal/wallet/model/Wallet"; + +describe("BuildPsbtTask", () => { + it("should build psbt and fill datastore", async () => { + // given + const psbtMapper = { + map: jest.fn(() => Right("InternalPsbt" as unknown as InternalPsbt)), + }; + const dataStoreService = { + merklizeWallet: jest.fn(), + merklizePsbt: jest.fn(() => + Right("PsbtCommitment" as unknown as PsbtCommitment), + ), + } as unknown as DataStoreService; + const dataStore = new DataStore(); + const task = new BuildPsbtTask( + { + wallet: "Wallet" as unknown as Wallet, + psbt: "ApiPsbt" as unknown as Psbt, + }, + dataStoreService, + psbtMapper, + () => dataStore, + ); + // when + const result = await task.run(); + // then + expect(psbtMapper.map).toHaveBeenCalledWith("ApiPsbt"); + expect(dataStoreService.merklizePsbt).toHaveBeenCalledWith( + dataStore, + "InternalPsbt", + ); + expect(dataStoreService.merklizeWallet).toHaveBeenCalledWith( + dataStore, + "Wallet", + ); + expect(result).toStrictEqual( + CommandResultFactory({ + data: { + psbtCommitment: "PsbtCommitment", + dataStore, + psbt: "InternalPsbt", + }, + }), + ); + }); + it("should return an error if datastore fails", async () => { + // given + const psbtMapper = { + map: jest.fn(() => Right({} as InternalPsbt)), + }; + const error = new Error("Failed"); + const dataStoreService = { + merklizeWallet: jest.fn(), + merklizePsbt: jest.fn(() => Left(error)), + merklizeChunks: jest.fn(), + }; + const task = new BuildPsbtTask( + { + wallet: {} as unknown as Wallet, + psbt: {} as unknown as Psbt, + }, + dataStoreService, + psbtMapper, + ); + // when + const result = await task.run(); + // then + expect(result).toStrictEqual( + CommandResultFactory({ + error: new UnknownDeviceExchangeError({ error }), + }), + ); + }); + it("should return an error if datastore fails", async () => { + // given + const error = new Error("Failed"); + const psbtMapper = { + map: jest.fn(() => Left(error)), + }; + const dataStoreService = { + merklizeWallet: jest.fn(), + merklizePsbt: jest.fn(() => Right({} as PsbtCommitment)), + merklizeChunks: jest.fn(), + }; + const task = new BuildPsbtTask( + { + wallet: {} as unknown as Wallet, + psbt: {} as unknown as Psbt, + }, + dataStoreService, + psbtMapper, + ); + // when + const result = await task.run(); + // then + expect(result).toStrictEqual( + CommandResultFactory({ + error: new UnknownDeviceExchangeError({ error }), + }), + ); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.ts new file mode 100644 index 000000000..198132eef --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.ts @@ -0,0 +1,62 @@ +import { + type CommandResult, + CommandResultFactory, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; +import { EitherAsync } from "purify-ts"; + +import { type Psbt } from "@api/model/Psbt"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { DataStore } from "@internal/data-store/model/DataStore"; +import { + type DataStoreService, + type PsbtCommitment, +} from "@internal/data-store/service/DataStoreService"; +import { type Psbt as InternalPsbt } from "@internal/psbt/model/Psbt"; +import type { PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import { type Wallet } from "@internal/wallet/model/Wallet"; + +export type BuildPsbtTaskResult = { + psbtCommitment: PsbtCommitment; + dataStore: DataStore; + psbt: InternalPsbt; +}; + +export class BuildPsbtTask { + constructor( + private readonly _args: { + wallet: Wallet; + psbt: Psbt; + }, + private readonly _dataStoreService: DataStoreService, + private readonly _psbtMapper: PsbtMapper, + private readonly _dataStoreFactory = () => new DataStore(), + ) {} + + async run(): Promise> { + const dataStore = this._dataStoreFactory(); + let psbt: InternalPsbt; + return await EitherAsync(async ({ liftEither }) => { + // map the input PSBT (V1 or V2, string or byte array) into a normalized and parsed PSBTv2 + psbt = await liftEither(this._psbtMapper.map(this._args.psbt)); + // put wallet policy and PSBT in merkle maps to expose them to the device + this._dataStoreService.merklizeWallet(dataStore, this._args.wallet); + return liftEither(this._dataStoreService.merklizePsbt(dataStore, psbt)); + }).caseOf({ + Left: (error) => { + return CommandResultFactory({ + error: new UnknownDeviceExchangeError({ error }), + }); + }, + Right: (psbtCommitment) => { + return CommandResultFactory({ + data: { + psbtCommitment, + dataStore, + psbt, + }, + }); + }, + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts new file mode 100644 index 000000000..9e9f70a5a --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.test.ts @@ -0,0 +1,116 @@ +import { + ApduResponse, + CommandResultFactory, + type DmkError, + type InternalApi, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; +import { type Either, Left, Right } from "purify-ts"; + +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { ContinueTask } from "@internal/app-binder/task/ContinueTask"; +import { type DataStore } from "@internal/data-store/model/DataStore"; + +describe("ContinueTask", () => { + const clientCommandInterpreter = { + getClientCommandPayload: jest.fn( + () => Right(Uint8Array.from([])) as Either, + ), + }; + const api = { + sendCommand: jest.fn(), + }; + const randomNumberOfClientCalls = Math.floor(Math.random() * 10 + 2); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it(`should call ${randomNumberOfClientCalls} times client interpreter and return success`, async () => { + // given + new Array(randomNumberOfClientCalls).fill(0).forEach((_) => { + api.sendCommand.mockReturnValueOnce( + CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }), + }), + ); + }); + api.sendCommand.mockReturnValueOnce( + CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }), + }), + ); + const fromResult = CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }), + }); + // when + const task = new ContinueTask( + api as unknown as InternalApi, + {} as DataStore, + clientCommandInterpreter, + ); + await task.run(fromResult); + // then + expect( + clientCommandInterpreter.getClientCommandPayload, + ).toHaveBeenCalledTimes(randomNumberOfClientCalls + 1); + }); + + it("should return an error if the client interpreter fails", async () => { + // given + const error = new UnknownDeviceExchangeError("Failed"); + clientCommandInterpreter.getClientCommandPayload.mockReturnValueOnce( + Left(error), + ); + const fromResult = CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }), + }); + // when + const task = new ContinueTask( + api as unknown as InternalApi, + {} as DataStore, + clientCommandInterpreter, + ); + const result = await task.run(fromResult); + // then + expect(api.sendCommand).toHaveBeenCalledTimes(0); + expect(result).toStrictEqual( + CommandResultFactory({ error: new UnknownDeviceExchangeError(error) }), + ); + }); + it("should return an error if send command fails", async () => { + // given + const error = new UnknownDeviceExchangeError("Failed"); + api.sendCommand.mockReturnValueOnce(CommandResultFactory({ error })); + const fromResult = CommandResultFactory({ + data: new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }), + }); + // when + const task = new ContinueTask( + api as unknown as InternalApi, + {} as DataStore, + clientCommandInterpreter, + ); + const result = await task.run(fromResult); + // then + expect( + clientCommandInterpreter.getClientCommandPayload, + ).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(CommandResultFactory({ error })); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts new file mode 100644 index 000000000..c1a462c97 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ContinueTask.ts @@ -0,0 +1,80 @@ +import { + type ApduResponse, + type CommandResult, + CommandResultFactory, + type CommandSuccessResult, + type InternalApi, + isSuccessCommandResult, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; + +import { type ClientCommandContext } from "@internal/app-binder/command/client-command-handlers/ClientCommandHandlersTypes"; +import { + ContinueCommand, + type ContinueCommandResponse, +} from "@internal/app-binder/command/ContinueCommand"; +import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { type DataStore } from "@internal/data-store/model/DataStore"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; + +export class ContinueTask { + private readonly _context: ClientCommandContext; + + constructor( + private readonly _api: InternalApi, + dataStore: DataStore, + private readonly _clientCommandInterpreter = new ClientCommandInterpreter(), + ) { + this._context = { + dataStore, + queue: [], + yieldedResults: [], + }; + } + + async run( + fromResult: CommandResult, + ): Promise> { + let currentResponse: CommandResult = + fromResult; + while ( + this.isApduResult(currentResponse) && + BtcCommandUtils.isContinueResponse(currentResponse.data) + ) { + currentResponse = await this._clientCommandInterpreter + .getClientCommandPayload(currentResponse.data.data, this._context) + .caseOf({ + Left: (error) => + Promise.resolve( + CommandResultFactory({ + error: new UnknownDeviceExchangeError(error), + }), + ), + Right: (payload) => + this._api.sendCommand( + new ContinueCommand({ + payload, + }), + ), + }); + } + return currentResponse; + } + + getYieldedResults() { + return this._context.yieldedResults; + } + + private isApduResult = ( + response: CommandResult, + ): response is CommandSuccessResult => { + return ( + isSuccessCommandResult(response) && + typeof response.data === "object" && + response.data !== null && + "statusCode" in response.data && + "data" in response.data + ); + }; +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.test.ts new file mode 100644 index 000000000..41649fe1c --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.test.ts @@ -0,0 +1,186 @@ +import { + CommandResultFactory, + type InternalApi, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; + +import { + DefaultDescriptorTemplate, + DefaultWallet, + RegisteredWallet, + type Wallet, +} from "@api/model/Wallet"; +import { PrepareWalletPolicyTask } from "@internal/app-binder/task/PrepareWalletPolicyTask"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; +const fromDefaultWalletMock = jest.fn(); +const fromRegisteredWalletMock = jest.fn(); + +describe("PrepareWalletPolicyTask", () => { + let internalApi: { sendCommand: jest.Mock }; + const walletBuilder = { + fromDefaultWallet: fromDefaultWalletMock, + fromRegisteredWallet: fromRegisteredWalletMock, + } as unknown as WalletBuilder; + beforeEach(() => { + internalApi = { + sendCommand: jest.fn(), + }; + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should return a builded wallet from a default one", async () => { + // given + const defaultWallet = new DefaultWallet( + "49'/0'/0'", + DefaultDescriptorTemplate.LEGACY, + ); + const task = new PrepareWalletPolicyTask( + internalApi as unknown as InternalApi, + { wallet: defaultWallet }, + walletBuilder, + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + extendedPublicKey: "xPublicKey", + }, + }), + ), + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + masterFingerprint: Uint8Array.from([0x42, 0x21, 0x12, 0x24]), + }, + }), + ), + ); + const wallet = {} as Wallet; + fromDefaultWalletMock.mockReturnValue(wallet); + // when + const result = await task.run(); + // then + expect(fromDefaultWalletMock).toHaveBeenCalledWith( + Uint8Array.from([0x42, 0x21, 0x12, 0x24]), + "xPublicKey", + defaultWallet, + ); + expect(result).toStrictEqual( + CommandResultFactory({ + data: wallet, + }), + ); + }); + + it("should return a builded wallet from a registered one", async () => { + // given + const registeredWallet = new RegisteredWallet( + "walletName", + DefaultDescriptorTemplate.LEGACY, + ["key0", "key1"], + Uint8Array.from([42]), + ); + const task = new PrepareWalletPolicyTask( + internalApi as unknown as InternalApi, + { wallet: registeredWallet }, + walletBuilder, + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + extendedPublicKey: "xPublicKey", + }, + }), + ), + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + masterFingerprint: Uint8Array.from([0x42, 0x21, 0x12, 0x24]), + }, + }), + ), + ); + const wallet = {} as Wallet; + fromRegisteredWalletMock.mockReturnValue(wallet); + // when + const result = await task.run(); + // then + expect(fromRegisteredWalletMock).toHaveBeenCalledWith(registeredWallet); + expect(result).toStrictEqual( + CommandResultFactory({ + data: wallet, + }), + ); + }); + + it("should return an error if getMasterFingerprint failed", async () => { + // given + const defaultWallet = new DefaultWallet( + "49'/0'/0'", + DefaultDescriptorTemplate.LEGACY, + ); + const task = new PrepareWalletPolicyTask( + internalApi as unknown as InternalApi, + { wallet: defaultWallet }, + walletBuilder, + ); + const error = new UnknownDeviceExchangeError("Failed"); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + error, + }), + ), + ); + // when + const result = await task.run(); + // then + expect(result).toStrictEqual(CommandResultFactory({ error })); + }); + + it("should return an error if getExtendedPublicKey failed", async () => { + // given + const defaultWallet = new DefaultWallet( + "49'/0'/0'", + DefaultDescriptorTemplate.LEGACY, + ); + const task = new PrepareWalletPolicyTask( + internalApi as unknown as InternalApi, + { wallet: defaultWallet }, + walletBuilder, + ); + const error = new UnknownDeviceExchangeError("Failed"); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + data: { + masterFingerprint: Uint8Array.from([0x42, 0x21, 0x12, 0x24]), + }, + }), + ), + ); + internalApi.sendCommand.mockResolvedValueOnce( + Promise.resolve( + CommandResultFactory({ + error, + }), + ), + ); + // when + const result = await task.run(); + // then + expect(result).toStrictEqual(CommandResultFactory({ error })); + expect(result).toStrictEqual( + CommandResultFactory({ + error, + }), + ); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.ts new file mode 100644 index 000000000..d127d90df --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.ts @@ -0,0 +1,66 @@ +import { + CommandResultFactory, + type InternalApi, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; + +import { + type DefaultWallet, + type Wallet as ApiWallet, +} from "@api/model/Wallet"; +import { GetExtendedPublicKeyCommand } from "@internal/app-binder/command/GetExtendedPublicKeyCommand"; +import { GetMasterFingerprintCommand } from "@internal/app-binder/command/GetMasterFingerprintCommand"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { type Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; + +export type PrepareWalletPolicyTaskArgs = { wallet: ApiWallet }; + +export class PrepareWalletPolicyTask { + constructor( + private readonly _api: InternalApi, + private readonly _args: PrepareWalletPolicyTaskArgs, + private readonly _walletBuilder: WalletBuilder, + ) {} + + private isDefaultWallet(wallet: ApiWallet): wallet is DefaultWallet { + return "derivationPath" in wallet; + } + + async run() { + const { wallet } = this._args; + + // Return build from a registered wallet + if (!this.isDefaultWallet(wallet)) { + return Promise.resolve( + CommandResultFactory({ + data: this._walletBuilder.fromRegisteredWallet(wallet), + }), + ); + } + // Get xpub and masterfingerprint for a default wallet + const xPubKeyResult = await this._api.sendCommand( + new GetExtendedPublicKeyCommand({ + checkOnDevice: false, + derivationPath: wallet.derivationPath, + }), + ); + if (!isSuccessCommandResult(xPubKeyResult)) { + return xPubKeyResult; + } + const masterFingerprintResult = await this._api.sendCommand( + new GetMasterFingerprintCommand(), + ); + if (!isSuccessCommandResult(masterFingerprintResult)) { + return masterFingerprintResult; + } + // Return build from a default wallet + return CommandResultFactory({ + data: this._walletBuilder.fromDefaultWallet( + masterFingerprintResult.data.masterFingerprint, + xPubKeyResult.data.extendedPublicKey, + wallet, + ), + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts index c298233f4..7585fb3ef 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.test.ts @@ -1,85 +1,46 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { - type ApduResponse, + ApduResponse, CommandResultFactory, - CommandResultStatus, type InternalApi, InvalidStatusWordError, - isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; -import { Left, Right } from "purify-ts"; import { type Signature } from "@api/model/Signature"; -import { ClientCommandHandlerError } from "@internal/app-binder/command/client-command-handlers/Errors"; -import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; import { CHUNK_SIZE, - ClientCommandCodes, SHA256_SIZE, - SW_INTERRUPTED_EXECUTION, } from "@internal/app-binder/command/utils/constants"; -import { DefaultDataStoreService } from "@internal/data-store/service/DefaultDataStoreService"; +import { type ContinueTask } from "@internal/app-binder/task/ContinueTask"; +import { DataStore } from "@internal/data-store/model/DataStore"; +import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; import { SendSignMessageTask } from "./SignMessageTask"; const EXACT_ONE_CHUNK_MESSAGE = "a".repeat(CHUNK_SIZE); const EXACT_TWO_CHUNKS_MESSAGE = "a".repeat(CHUNK_SIZE * 2); const DERIVATION_PATH = "44'/0'/0'/0/0"; -const PREIMAGE = new Uint8Array([1, 2, 3, 4]); const MERKLE_ROOT = new Uint8Array(SHA256_SIZE).fill(0x01); const SIGNATURE: Signature = { v: 27, - r: "0x1212121212121212121212121212121212121212121212121212121212121212", - s: "0x3434343434343434343434343434343434343434343434343434343434343434", + r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", + s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", }; -const APDU_RESPONSE_YELD: ApduResponse = { - statusCode: SW_INTERRUPTED_EXECUTION, - data: new Uint8Array([ClientCommandCodes.YIELD]), -}; - -// Helper function to create a mock signature response -const getSignatureResponse = ({ - omitV = false, - omitR = false, - omitS = false, -}: { - omitV?: boolean; - omitR?: boolean; - omitS?: boolean; -} = {}) => - omitV - ? new Uint8Array([]) - : new Uint8Array([ - // v - ...(omitR ? [] : [0x1b]), - // r (32 bytes) unless omitted - ...(omitR - ? [] - : [ - 0x97, 0xa4, 0xca, 0x8f, 0x69, 0x46, 0x33, 0x59, 0x26, 0x01, 0xf5, - 0xa2, 0x3e, 0x0b, 0xcc, 0x55, 0x3c, 0x9d, 0x0a, 0x90, 0xd3, 0xa3, - 0x42, 0x2d, 0x57, 0x55, 0x08, 0xa9, 0x28, 0x98, 0xb9, 0x6e, - ]), - // s (32 bytes) unless omitted - ...(omitS - ? [] - : [ - 0x69, 0x50, 0xd0, 0x2e, 0x74, 0xe9, 0xc1, 0x02, 0xc1, 0x64, 0xa2, - 0x25, 0x53, 0x30, 0x82, 0xca, 0xbd, 0xd8, 0x90, 0xef, 0xc4, 0x63, - 0xf6, 0x7f, 0x60, 0xce, 0xfe, 0x8c, 0x3f, 0x87, 0xcf, 0xce, - ]), - ]); - -const USER_DENIED_STATUS = new Uint8Array([0x69, 0x85]); +const SIGNATURE_APDU = new Uint8Array([ + 0x1b, 0x97, 0xa4, 0xca, 0x8f, 0x69, 0x46, 0x33, 0x59, 0x26, 0x01, 0xf5, 0xa2, + 0x3e, 0x0b, 0xcc, 0x55, 0x3c, 0x9d, 0x0a, 0x90, 0xd3, 0xa3, 0x42, 0x2d, 0x57, + 0x55, 0x08, 0xa9, 0x28, 0x98, 0xb9, 0x6e, 0x69, 0x50, 0xd0, 0x2e, 0x74, 0xe9, + 0xc1, 0x02, 0xc1, 0x64, 0xa2, 0x25, 0x53, 0x30, 0x82, 0xca, 0xbd, 0xd8, 0x90, + 0xef, 0xc4, 0x63, 0xf6, 0x7f, 0x60, 0xce, 0xfe, 0x8c, 0x3f, 0x87, 0xcf, 0xce, +]); describe("SignMessageTask", () => { - const signatureResult = CommandResultFactory({ - data: SIGNATURE, + const signatureResult = CommandResultFactory({ + data: new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: SIGNATURE_APDU, + }), }); const apiMock = { sendCommand: jest.fn(), @@ -97,20 +58,28 @@ describe("SignMessageTask", () => { message: EXACT_ONE_CHUNK_MESSAGE, }; - jest - .spyOn(DefaultDataStoreService.prototype, "merklizeChunks") - .mockImplementation((_, chunks) => { - expect(chunks.length).toBe(1); - return MERKLE_ROOT; - }); + const dataStoreService = { + merklizeChunks: jest.fn().mockReturnValue(MERKLE_ROOT), + } as unknown as DataStoreService; - (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(signatureResult); + const continueTaskFactory = () => + ({ + run: jest.fn().mockReturnValue(signatureResult), + }) as unknown as ContinueTask; // WHEN - const result = await new SendSignMessageTask(apiMock, args).run(); + const result = await new SendSignMessageTask( + apiMock, + args, + dataStoreService, + continueTaskFactory, + ).run(); // THEN - expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); + expect(dataStoreService.merklizeChunks).toHaveBeenCalledWith( + expect.any(DataStore), + [Uint8Array.from(new Array(64).fill(0x61))], + ); expect(result).toStrictEqual(CommandResultFactory({ data: SIGNATURE })); }); @@ -121,126 +90,32 @@ describe("SignMessageTask", () => { message: EXACT_TWO_CHUNKS_MESSAGE, }; - jest - .spyOn(DefaultDataStoreService.prototype, "merklizeChunks") - .mockImplementation((_, chunks) => { - expect(chunks.length).toBe(2); - return MERKLE_ROOT; - }); - - (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(signatureResult); - - // WHEN - const result = await new SendSignMessageTask(apiMock, args).run(); - - // THEN - expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(CommandResultFactory({ data: SIGNATURE })); - }); - - it("should handle interrupted execution with interactive commands", async () => { - // GIVEN - const args = { - derivationPath: DERIVATION_PATH, - message: EXACT_TWO_CHUNKS_MESSAGE, - }; - - jest - .spyOn(DefaultDataStoreService.prototype, "merklizeChunks") - .mockImplementation((_, chunks) => { - expect(chunks.length).toBe(2); - return MERKLE_ROOT; - }); - - (apiMock.sendCommand as jest.Mock) - .mockResolvedValueOnce( - CommandResultFactory({ - data: APDU_RESPONSE_YELD, - }), - ) - .mockResolvedValueOnce( - CommandResultFactory({ - data: { - statusCode: SW_INTERRUPTED_EXECUTION, - data: new Uint8Array([ClientCommandCodes.GET_PREIMAGE]), - }, - }), - ) - .mockResolvedValueOnce(signatureResult); - - const getClientCommandPayloadMock = jest - .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") + const dataStoreService = { + merklizeChunks: jest.fn().mockReturnValue(MERKLE_ROOT), + } as unknown as DataStoreService; - .mockImplementation((request: Uint8Array, context: any) => { - const commandCode = request[0]; - if (commandCode === ClientCommandCodes.YIELD) { - // simulate YIELD command - context.yieldedResults.push(new Uint8Array([])); - return Right(new Uint8Array([0x00])); - } - if (commandCode === ClientCommandCodes.GET_PREIMAGE) { - // simulate GET_PREIMAGE command - return Right(PREIMAGE); - } - return Left(new ClientCommandHandlerError("error")); - }); + const continueTaskFactory = () => + ({ + run: jest.fn().mockReturnValue(signatureResult), + }) as unknown as ContinueTask; // WHEN - const result = await new SendSignMessageTask(apiMock, args).run(); + const result = await new SendSignMessageTask( + apiMock, + args, + dataStoreService, + continueTaskFactory, + ).run(); // THEN - // expected number of sendCommand calls: - // 1. SignMessageCommand - // 2. ContinueCommand after YIELD - // 3. ContinueCommand after GET_PREIMAGE - expect(apiMock.sendCommand).toHaveBeenCalledTimes(3); - - // check that sendCommand was called with the correct commands - expect(apiMock.sendCommand).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - args: { - derivationPath: DERIVATION_PATH, - messageLength: new TextEncoder().encode(EXACT_TWO_CHUNKS_MESSAGE) - .length, - messageMerkleRoot: MERKLE_ROOT, - }, - }), - ); - - expect(apiMock.sendCommand).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - args: { - payload: new Uint8Array([0x00]), - }, - }), - ); - - expect(apiMock.sendCommand).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - args: { - payload: PREIMAGE, - }, - }), + expect(dataStoreService.merklizeChunks).toHaveBeenCalledWith( + expect.any(DataStore), + [ + Uint8Array.from(new Array(64).fill(0x61)), + Uint8Array.from(new Array(64).fill(0x61)), + ], ); - - // check the final result expect(result).toStrictEqual(CommandResultFactory({ data: SIGNATURE })); - - // check that getClientCommandPayload was called correctly - expect(getClientCommandPayloadMock).toHaveBeenCalledTimes(2); - expect(getClientCommandPayloadMock).toHaveBeenNthCalledWith( - 1, - new Uint8Array([ClientCommandCodes.YIELD]), - expect.any(Object), - ); - expect(getClientCommandPayloadMock).toHaveBeenNthCalledWith( - 2, - new Uint8Array([ClientCommandCodes.GET_PREIMAGE]), - expect.any(Object), - ); }); it("should return an error if the initial SignMessageCommand fails", async () => { @@ -253,250 +128,29 @@ describe("SignMessageTask", () => { const resultError = CommandResultFactory({ error: new InvalidStatusWordError("error"), }); + const dataStoreService = { + merklizeChunks: jest.fn().mockReturnValue(MERKLE_ROOT), + } as unknown as DataStoreService; - jest - .spyOn(DefaultDataStoreService.prototype, "merklizeChunks") - .mockImplementation((_, chunks) => { - expect(chunks.length).toBe(1); - return MERKLE_ROOT; - }); - - (apiMock.sendCommand as jest.Mock).mockResolvedValueOnce(resultError); - - // WHEN - const result = await new SendSignMessageTask(apiMock, args).run(); - - // THEN - expect(apiMock.sendCommand).toHaveBeenCalledTimes(1); - expect(result.status).toBe(CommandResultStatus.Error); - if (result.status === CommandResultStatus.Error) { - expect(result.error).toBeInstanceOf(InvalidStatusWordError); - } - }); - - it("should return an error if a ContinueCommand fails during interactive execution", async () => { - // GIVEN - const args = { - derivationPath: DERIVATION_PATH, - message: EXACT_TWO_CHUNKS_MESSAGE, - }; - - jest - .spyOn(DefaultDataStoreService.prototype, "merklizeChunks") - .mockImplementation((_, chunks) => { - expect(chunks.length).toBe(2); - return MERKLE_ROOT; - }); - - const resultError = CommandResultFactory({ - error: new InvalidStatusWordError("error"), - }); - - (apiMock.sendCommand as jest.Mock) - .mockResolvedValueOnce( - CommandResultFactory({ - data: APDU_RESPONSE_YELD, - }), - ) - .mockResolvedValueOnce(resultError); - - const getClientCommandPayloadMock = jest - .spyOn(ClientCommandInterpreter.prototype, "getClientCommandPayload") - - .mockImplementation((request: Uint8Array, context: any) => { - const commandCode = request[0]; - if (commandCode === ClientCommandCodes.YIELD) { - // simulate YIELD command - context.yieldedResults.push(new Uint8Array([])); - return Right(new Uint8Array([0x00])); - } - // no need GET_PREIMAGE since as it should fail before - return Left(new ClientCommandHandlerError("error")); - }); + const continueTaskFactory = () => + ({ + run: jest.fn().mockReturnValue(resultError), + }) as unknown as ContinueTask; // WHEN - const result = await new SendSignMessageTask(apiMock, args).run(); + const result = await new SendSignMessageTask( + apiMock, + args, + dataStoreService, + continueTaskFactory, + ).run(); // THEN - // expected number of sendCommand calls: - // 1. SignMessageCommand - // 2. ContinueCommand after YIELD (which fails) - expect(apiMock.sendCommand).toHaveBeenCalledTimes(2); - - // check that sendCommand was called with the correct commands - expect(apiMock.sendCommand).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - args: { - derivationPath: DERIVATION_PATH, - messageLength: new TextEncoder().encode(EXACT_TWO_CHUNKS_MESSAGE) - .length, - messageMerkleRoot: MERKLE_ROOT, - }, - }), - ); - - expect(apiMock.sendCommand).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - args: { - payload: new Uint8Array([0x00]), - }, + expect(result).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("error"), }), ); - - // check the final result - expect(result.status).toBe(CommandResultStatus.Error); - if (result.status === CommandResultStatus.Error) { - expect(result.error).toBeInstanceOf(InvalidStatusWordError); - } - - // check that getClientCommandPayload was called correctly - expect(getClientCommandPayloadMock).toHaveBeenCalledTimes(1); - expect(getClientCommandPayloadMock).toHaveBeenNthCalledWith( - 1, - new Uint8Array([ClientCommandCodes.YIELD]), - expect.any(Object), - ); - }); - }); - - describe("parseBitcoinSignatureResponse", () => { - let instance: SendSignMessageTask; - - beforeEach(() => { - instance = new SendSignMessageTask(apiMock, { - derivationPath: DERIVATION_PATH, - message: "test", - }); - }); - - it("should return a continuation response if it's a continue response", () => { - const apduResponse: ApduResponse = { - statusCode: SW_INTERRUPTED_EXECUTION, - data: new Uint8Array([ClientCommandCodes.YIELD]), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - expect(result.status).toBe(CommandResultStatus.Success); - if (isSuccessCommandResult(result)) { - expect(result.data).toEqual(apduResponse); - } - }); - - it("should return a global error if not success and not a known continuation", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x6a, 0x80]), - data: new Uint8Array([]), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeDefined(); - } - }); - - it("should return a bitcoin app command error if the error code matches a known bitcoin app error", () => { - const apduResponse: ApduResponse = { - statusCode: USER_DENIED_STATUS, - data: new Uint8Array([]), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeDefined(); - } - }); - - it("should return an error if 'v' is missing", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x90, 0x00]), - data: getSignatureResponse({ omitV: true }), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(InvalidStatusWordError); - expect(result).toStrictEqual( - CommandResultFactory({ - error: new InvalidStatusWordError("V is missing"), - }), - ); - } - }); - - it("should return an error if 'r' is missing", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x90, 0x00]), - data: getSignatureResponse({ omitR: true }), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(InvalidStatusWordError); - expect(result).toStrictEqual( - CommandResultFactory({ - error: new InvalidStatusWordError("R is missing"), - }), - ); - } - }); - - it("should return an error if 's' is missing", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x90, 0x00]), - data: getSignatureResponse({ omitS: true }), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - - expect(result.status).toBe(CommandResultStatus.Error); - if (!isSuccessCommandResult(result)) { - expect(result.error).toBeInstanceOf(InvalidStatusWordError); - expect(result).toStrictEqual( - CommandResultFactory({ - error: new InvalidStatusWordError("S is missing"), - }), - ); - } - }); - - it("should return a signature if v, r, and s are present", () => { - const apduResponse: ApduResponse = { - statusCode: new Uint8Array([0x90, 0x00]), - data: getSignatureResponse(), - }; - - const result = (instance as any).parseBitcoinSignatureResponse( - apduResponse, - ); - - expect(result.status).toBe(CommandResultStatus.Success); - if (isSuccessCommandResult(result)) { - expect(result.data).toEqual({ - v: 27, - r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", - s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", - }); - } }); }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts index 0e49c74f9..312e9d773 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignMessageTask.ts @@ -1,40 +1,17 @@ import { - ApduParser, - type ApduResponse, type CommandResult, - CommandResultFactory, - GlobalCommandErrorHandler, type InternalApi, - InvalidStatusWordError, - isCommandErrorCode, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; import { type Signature } from "@api/model/Signature"; -import { type ClientCommandContext } from "@internal/app-binder/command/client-command-handlers/ClientCommandHandlersTypes"; -import { ContinueCommand } from "@internal/app-binder/command/ContinueCommand"; -import { ClientCommandInterpreter } from "@internal/app-binder/command/service/ClientCommandInterpreter"; -import { - SignMessageCommand, - type SignMessageCommandResponse, -} from "@internal/app-binder/command/SignMessageCommand"; -import { - BitcoinAppCommandError, - bitcoinAppErrors, -} from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { SignMessageCommand } from "@internal/app-binder/command/SignMessageCommand"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { CHUNK_SIZE } from "@internal/app-binder/command/utils/constants"; +import { ContinueTask } from "@internal/app-binder/task/ContinueTask"; import { DataStore } from "@internal/data-store/model/DataStore"; import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; -import { DefaultDataStoreService } from "@internal/data-store/service/DefaultDataStoreService"; -import { MerkleMapBuilder } from "@internal/merkle-tree/service/MerkleMapBuilder"; -import { MerkleTreeBuilder } from "@internal/merkle-tree/service/MerkleTreeBuilder"; -import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; -import { CommandUtils } from "@internal/utils/CommandUtils"; -import { CommandUtils as BtcCommandUtils } from "@internal/utils/CommandUtils"; -import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; - -const R_LENGTH = 32; -const S_LENGTH = 32; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; export type SendSignMessageTaskArgs = { derivationPath: string; @@ -42,28 +19,18 @@ export type SendSignMessageTaskArgs = { }; export class SendSignMessageTask { - private dataStoreService: DataStoreService; - constructor( - private api: InternalApi, - private args: SendSignMessageTaskArgs, - ) { - const merkleTreeBuilder = new MerkleTreeBuilder(new Sha256HasherService()); - const merkleMapBuilder = new MerkleMapBuilder(merkleTreeBuilder); - const walletSerializer = new DefaultWalletSerializer( - new Sha256HasherService(), - ); - - this.dataStoreService = new DefaultDataStoreService( - merkleTreeBuilder, - merkleMapBuilder, - walletSerializer, - new Sha256HasherService(), - ); - } - - async run(): Promise> { - const { derivationPath, message } = this.args; + private readonly _api: InternalApi, + private readonly _args: SendSignMessageTaskArgs, + private readonly _dataStoreService: DataStoreService, + private readonly _continueTaskFactory = ( + api: InternalApi, + dataStore: DataStore, + ) => new ContinueTask(api, dataStore), + ) {} + + async run(): Promise> { + const { derivationPath, message } = this._args; const dataStore = new DataStore(); @@ -73,166 +40,21 @@ export class SendSignMessageTask { chunks.push(messageBuffer.subarray(i, i + CHUNK_SIZE)); } - const merkleRoot = this.dataStoreService.merklizeChunks(dataStore, chunks); - - const interpreter = new ClientCommandInterpreter(); + const merkleRoot = this._dataStoreService.merklizeChunks(dataStore, chunks); - const commandHandlersContext: ClientCommandContext = { - dataStore, - queue: [], - yieldedResults: [], - }; - - const signMessageFirstCommandResponse = await this.api.sendCommand( + const signMessageFirstCommandResponse = await this._api.sendCommand( new SignMessageCommand({ derivationPath, messageLength: messageBuffer.length, messageMerkleRoot: merkleRoot, }), ); - if (!isSuccessCommandResult(signMessageFirstCommandResponse)) { - return CommandResultFactory({ - error: new InvalidStatusWordError( - "Invalid signMessageFirstCommandResponse response", - ), - }); - } - - if (this.isSignature(signMessageFirstCommandResponse.data)) { - return CommandResultFactory({ - data: signMessageFirstCommandResponse.data, - }); - } - - let currentResponse = signMessageFirstCommandResponse; - while ( - this.isApduResponse(currentResponse.data) && - CommandUtils.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.parseBitcoinSignatureResponse, - ), - ); - if (!isSuccessCommandResult(nextResponse)) { - return CommandResultFactory({ - error: new InvalidStatusWordError("Invalid response type"), - }); - } - if (this.isSignature(nextResponse.data)) { - return CommandResultFactory({ - data: nextResponse.data, - }); - } - - currentResponse = nextResponse; - } - } - - return CommandResultFactory({ - error: new InvalidStatusWordError("Failed to send sign message command."), - }); - } - - private isSignature = ( - response: SignMessageCommandResponse, - ): response is Signature => { - return ( - response && - typeof response === "object" && - "v" in response && - "r" in response && - "s" in response - ); - }; - - private isApduResponse = ( - response: SignMessageCommandResponse, - ): response is ApduResponse => { - return ( - response && - typeof response === "object" && - "statusCode" in response && - "data" in response - ); - }; - - private parseBitcoinSignatureResponse( - 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, - }), - }); - } - - const v = parser.extract8BitUInt(); - if (v === undefined) { - return CommandResultFactory({ - error: new InvalidStatusWordError("V is missing"), - }); - } - - const r = parser.encodeToHexaString( - parser.extractFieldByLength(R_LENGTH), - true, + const response = await this._continueTaskFactory(this._api, dataStore).run( + signMessageFirstCommandResponse, ); - if (!r) { - return CommandResultFactory({ - error: new InvalidStatusWordError("R is missing"), - }); + if (isSuccessCommandResult(response)) { + return BtcCommandUtils.getSignature(response); } - - const s = parser.encodeToHexaString( - parser.extractFieldByLength(S_LENGTH), - true, - ); - if (!s) { - return CommandResultFactory({ - error: new InvalidStatusWordError("S is missing"), - }); - } - - return CommandResultFactory({ - data: { - v, - r, - s, - }, - }); + return response; } } diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts new file mode 100644 index 000000000..50272e271 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts @@ -0,0 +1,380 @@ +import { + CommandResultFactory, + type InternalApi, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; +import { Maybe, Nothing } from "purify-ts"; + +import { SignPsbtCommand } from "@internal/app-binder/command/SignPsbtCommand"; +import { type ContinueTask } from "@internal/app-binder/task/ContinueTask"; +import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; +import { type DataStore } from "@internal/data-store/model/DataStore"; +import type { PsbtCommitment } from "@internal/data-store/service/DataStoreService"; +import { type Psbt } from "@internal/psbt/model/Psbt"; +import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { type Wallet } from "@internal/wallet/model/Wallet"; +import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +const SIGN_PSBT_YIELD_PARIAL_SIG_RESULT = Uint8Array.from([ + 0x00, 0x20, 0xf1, 0xe8, 0x42, 0x44, 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, + 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, 0xa9, 0x3a, 0xb9, 0x6c, 0xc1, 0xee, 0xee, + 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, 0x5f, 0x5f, 0x12, 0x4d, 0x63, 0x5c, 0xf2, + 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, + 0x96, 0xdf, 0xb0, 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, + 0x66, 0x3c, 0x59, 0x59, 0x41, 0x13, 0xe5, 0x71, 0x00, 0x06, 0x3d, 0x9d, 0xcc, + 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, 0xf8, 0x0a, 0x8f, 0x11, 0x50, 0xfd, 0x59, + 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, 0xd2, +]); + +const SIGN_PSBT_YIELD_MUSIG_PARIAL_SIG_RESULT = Uint8Array.from([ + 0xff, 0xfe, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe8, 0x42, 0x44, + 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, + 0xa9, 0x3a, 0xb9, 0x6c, 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, + 0x5f, 0x5f, 0x12, 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, 0x9d, + 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, 0x66, 0x3c, 0x59, 0x59, 0x41, 0x13, + 0xe5, 0x71, 0x00, 0x06, 0x3d, 0x9d, 0xcc, 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, + 0xf8, 0x0a, 0x8f, 0x11, 0x50, 0xfd, 0x59, 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, + 0xd2, 0xfe, 0xee, 0x33, 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, + 0xe2, 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, + 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, 0x66, 0x3c, +]); + +const SIGN_PSBT_YIELD_MUSIG_PUB_NONCE_RESULT = Uint8Array.from([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe8, 0x42, 0x44, + 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, + 0xa9, 0x3a, 0xb9, 0x6c, 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, + 0x5f, 0x5f, 0x12, 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, 0x9d, + 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, 0x66, 0x3c, 0x59, 0x01, 0x4d, 0x63, + 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, 0x77, 0x71, 0x2e, 0xad, 0x07, + 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, + 0x9a, 0x01, 0x66, 0x3c, 0x59, 0x59, 0x41, 0x13, 0xe5, 0x71, 0x00, 0x06, 0x3d, + 0x9d, 0xcc, 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, 0xf8, 0x0a, 0x8f, 0x11, 0x50, + 0xfd, 0x59, 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, 0xd2, 0xfe, 0xee, 0x33, 0x4d, + 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, 0x77, 0x71, 0x2e, 0xad, + 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, + 0xbd, 0x9a, 0x01, 0x66, 0x3c, +]); + +describe("SignPsbtTask", () => { + describe("run", () => { + it("should return partial signatures", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = { + getGlobalValue: () => Maybe.of(Uint8Array.from([0x03])), + } as unknown as Psbt; + const wallet = { + hmac: Uint8Array.from([0x04]), + } as Wallet; + const psbtCommitment = { + globalCommitment: Uint8Array.from([0x03]), + inputsRoot: Uint8Array.from([0x01]), + outputsRoot: Uint8Array.from([0x02]), + } as PsbtCommitment; + const dataStore = {} as DataStore; + const walletSerializer = { + getId: jest.fn(() => Uint8Array.from([0x05])), + } as unknown as WalletSerializer; + const valueParser = { + getVarint: jest.fn(() => Maybe.of(42)), + } as unknown as ValueParser; + const continueTaskFactory = () => + ({ + run: jest.fn().mockResolvedValue( + CommandResultFactory({ + data: [], + }), + ), + getYieldedResults: () => [SIGN_PSBT_YIELD_PARIAL_SIG_RESULT], + }) as unknown as ContinueTask; + + // when + const signatures = await new SignPsbtTask( + api, + { + psbt, + wallet, + psbtCommitment, + dataStore, + }, + walletSerializer, + valueParser, + continueTaskFactory, + ).run(); + // then + expect(api.sendCommand).toHaveBeenCalledWith( + new SignPsbtCommand({ + globalCommitment: Uint8Array.from([0x03]), + inputsCount: 42, + inputsRoot: Uint8Array.from([0x01]), + outputsCount: 42, + outputsRoot: Uint8Array.from([0x02]), + walletId: Uint8Array.from([0x05]), + walletHmac: Uint8Array.from([0x04]), + }), + ); + expect(signatures).toStrictEqual( + CommandResultFactory({ + data: [ + { + inputIndex: 0, + pubkey: Uint8Array.from([ + 0xf1, 0xe8, 0x42, 0x44, 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, + 0xa8, 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, 0xa9, 0x3a, 0xb9, + 0x6c, 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, + 0x5f, 0x5f, + ]), + signature: Uint8Array.from([ + 0x12, 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, + 0xe2, 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, + 0xb0, 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, + 0x01, 0x66, 0x3c, 0x59, 0x59, 0x41, 0x13, 0xe5, 0x71, 0x00, + 0x06, 0x3d, 0x9d, 0xcc, 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, + 0xf8, 0x0a, 0x8f, 0x11, 0x50, 0xfd, 0x59, 0xd9, 0xfe, 0xb7, + 0x9e, 0x25, 0x3b, 0xd2, + ]), + }, + ], + }), + ); + }); + it("should return musig partial signatures", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = { + getGlobalValue: () => Maybe.of(Uint8Array.from([0x03])), + } as unknown as Psbt; + const wallet = { + hmac: Uint8Array.from([0x04]), + } as Wallet; + const psbtCommitment = { + globalCommitment: Uint8Array.from([0x03]), + inputsRoot: Uint8Array.from([0x01]), + outputsRoot: Uint8Array.from([0x02]), + } as PsbtCommitment; + const dataStore = {} as DataStore; + const walletSerializer = { + getId: jest.fn(() => Uint8Array.from([0x05])), + } as unknown as WalletSerializer; + const valueParser = { + getVarint: jest.fn(() => Maybe.of(42)), + } as unknown as ValueParser; + const continueTaskFactory = () => + ({ + run: jest.fn().mockResolvedValue( + CommandResultFactory({ + data: [], + }), + ), + getYieldedResults: () => [SIGN_PSBT_YIELD_MUSIG_PARIAL_SIG_RESULT], + }) as unknown as ContinueTask; + + // when + const signatures = await new SignPsbtTask( + api, + { + psbt, + wallet, + psbtCommitment, + dataStore, + }, + walletSerializer, + valueParser, + continueTaskFactory, + ).run(); + // then + expect(api.sendCommand).toHaveBeenCalledWith( + new SignPsbtCommand({ + globalCommitment: Uint8Array.from([0x03]), + inputsCount: 42, + inputsRoot: Uint8Array.from([0x01]), + outputsCount: 42, + outputsRoot: Uint8Array.from([0x02]), + walletId: Uint8Array.from([0x05]), + walletHmac: Uint8Array.from([0x04]), + }), + ); + expect(signatures).toStrictEqual( + CommandResultFactory({ + data: [ + { + inputIndex: 0, + partialSignature: Uint8Array.from([ + 0xe8, 0x42, 0x44, 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, + 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, 0xa9, 0x3a, 0xb9, 0x6c, + 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, 0x5f, + 0x5f, 0x12, + ]), + participantPubkey: Uint8Array.from([ + 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, + 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, + 0x66, 0x3c, 0x59, + ]), + aggregatedPubkey: Uint8Array.from([ + 0x59, 0x41, 0x13, 0xe5, 0x71, 0x00, 0x06, 0x3d, 0x9d, 0xcc, + 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, 0xf8, 0x0a, 0x8f, 0x11, + 0x50, 0xfd, 0x59, 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, 0xd2, + 0xfe, 0xee, 0x33, + ]), + tapleafHash: Uint8Array.from([ + 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, + 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, + 0x66, 0x3c, + ]), + }, + ], + }), + ); + }); + it("should return musig pub nonce", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = { + getGlobalValue: () => Maybe.of(Uint8Array.from([0x03])), + } as unknown as Psbt; + const wallet = { + hmac: Uint8Array.from([0x04]), + } as Wallet; + const psbtCommitment = { + globalCommitment: Uint8Array.from([0x03]), + inputsRoot: Uint8Array.from([0x01]), + outputsRoot: Uint8Array.from([0x02]), + } as PsbtCommitment; + const dataStore = {} as DataStore; + const walletSerializer = { + getId: jest.fn(() => Uint8Array.from([0x05])), + } as unknown as WalletSerializer; + const valueParser = { + getVarint: jest.fn(() => Maybe.of(42)), + } as unknown as ValueParser; + const continueTaskFactory = () => + ({ + run: jest.fn().mockResolvedValue( + CommandResultFactory({ + data: [], + }), + ), + getYieldedResults: () => [SIGN_PSBT_YIELD_MUSIG_PUB_NONCE_RESULT], + }) as unknown as ContinueTask; + + // when + const signatures = await new SignPsbtTask( + api, + { + psbt, + wallet, + psbtCommitment, + dataStore, + }, + walletSerializer, + valueParser, + continueTaskFactory, + ).run(); + // then + expect(api.sendCommand).toHaveBeenCalledWith( + new SignPsbtCommand({ + globalCommitment: Uint8Array.from([0x03]), + inputsCount: 42, + inputsRoot: Uint8Array.from([0x01]), + outputsCount: 42, + outputsRoot: Uint8Array.from([0x02]), + walletId: Uint8Array.from([0x05]), + walletHmac: Uint8Array.from([0x04]), + }), + ); + expect(signatures).toStrictEqual( + CommandResultFactory({ + data: [ + { + inputIndex: 0, + pubnonce: Uint8Array.from([ + 0xe8, 0x42, 0x44, 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, + 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, 0xa9, 0x3a, 0xb9, 0x6c, + 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, 0x5f, + 0x5f, 0x12, 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, + 0x7b, 0xe2, 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, + 0xdf, 0xb0, 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, + 0x9a, 0x01, 0x66, 0x3c, 0x59, 0x01, + ]), + participantPubkey: Uint8Array.from([ + 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, + 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, + 0x66, 0x3c, 0x59, + ]), + aggregatedPubkey: Uint8Array.from([ + 0x59, 0x41, 0x13, 0xe5, 0x71, 0x00, 0x06, 0x3d, 0x9d, 0xcc, + 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, 0xf8, 0x0a, 0x8f, 0x11, + 0x50, 0xfd, 0x59, 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, 0xd2, + 0xfe, 0xee, 0x33, + ]), + tapleafHash: Uint8Array.from([ + 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, + 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, + 0x66, 0x3c, + ]), + }, + ], + }), + ); + }); + }); + describe("errors", () => { + it("should return an error if continue task fails", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = { + getGlobalValue: jest.fn(() => Nothing), + } as unknown as Psbt; + const wallet = {} as Wallet; + const psbtCommitment = {} as PsbtCommitment; + const dataStore = {} as DataStore; + const walletSerializer = { + getId: jest.fn(() => Uint8Array.from([0x05])), + } as unknown as WalletSerializer; + const valueParser = { + getVarint: jest.fn(() => Maybe.of(42)), + } as unknown as ValueParser; + const continueTaskFactory = () => + ({ + run: jest.fn().mockResolvedValue( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Failed"), + }), + ), + }) as unknown as ContinueTask; + // when + const result = await new SignPsbtTask( + api, + { + psbt, + wallet, + psbtCommitment, + dataStore, + }, + walletSerializer, + valueParser, + continueTaskFactory, + ).run(); + // then + expect(result).toStrictEqual( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Failed"), + }), + ); + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts new file mode 100644 index 000000000..f58892645 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts @@ -0,0 +1,293 @@ +import { + ByteArrayParser, + type CommandResult, + CommandResultFactory, + type InternalApi, + InvalidStatusWordError, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; +import { Either, Left, Maybe, Right } from "purify-ts"; + +import { + type MusigPartialSignature, + type MusigPubNonce, + type PartialSignature, + type PsbtSignature, +} from "@api/model/Signature"; +import { SignPsbtCommand } from "@internal/app-binder/command/SignPsbtCommand"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { type BuildPsbtTaskResult } from "@internal/app-binder/task/BuildPsbtTask"; +import { ContinueTask } from "@internal/app-binder/task/ContinueTask"; +import { type DataStore } from "@internal/data-store/model/DataStore"; +import { PsbtGlobal } from "@internal/psbt/model/Psbt"; +import type { ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { extractVarint } from "@internal/utils/Varint"; +import { type Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; +import type { WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +export type SignPsbtTaskArgs = BuildPsbtTaskResult & { + wallet: InternalWallet; +}; + +export type SignPsbtTaskResult = CommandResult; + +const MUSIG_PUBNONCE_TAG = 0xffffffff; +const MUSIG_PARTIAL_SIGNATURE_TAG = 0xfffffffe; +const PARTIAL_SIGNATURE_MAX_TAG = 0xffff; +const PUBKEY_LENGTH = 32; +const PUBKEY_LENGTH_COMPRESSED = 33; +const PUBKEY_LENGTH_TAPLEAF = 64; +const PARTIAL_SIGNATURE_LENGTH = 32; +const PUBNONCE_LENGTH = 66; + +export class SignPsbtTask { + constructor( + private readonly _api: InternalApi, + private readonly _args: SignPsbtTaskArgs, + private readonly _walletSerializer: WalletSerializer, + private readonly _valueParser: ValueParser, + private readonly _continueTaskFactory = ( + api: InternalApi, + dataStore: DataStore, + ) => new ContinueTask(api, dataStore), + ) {} + + /** + * Executes the task of signing a PSBT (Partially Signed Bitcoin Transaction) by processing + * the necessary PSBT components, sending the signing command, and handling the responses. + * + * The method first extracts the required data from the PSBT and wallet arguments, sends a signing + * PSBT command using these details, and continues the task execution using the resulting command output. + * + * If the command results in success, it decodes the returned signatures, performing error handling for failures + * in decoding. Finally, it returns the successfully decoded signatures or an error response. + * + * @return {Promise} A promise that resolves with the result of the PSBT signing process. + * This can either be a success object containing decoded PSBT signatures or an error result. + */ + async run(): Promise { + const { + psbtCommitment: { globalCommitment, inputsRoot, outputsRoot }, + psbt, + wallet, + dataStore, + } = this._args; + const signPsbtCommandResult = await this._api.sendCommand( + new SignPsbtCommand({ + globalCommitment, + inputsRoot, + outputsRoot, + inputsCount: psbt + .getGlobalValue(PsbtGlobal.INPUT_COUNT) + .chain((value) => this._valueParser.getVarint(value.data)) + .orDefault(0), + outputsCount: psbt + .getGlobalValue(PsbtGlobal.OUTPUT_COUNT) + .chain((value) => this._valueParser.getVarint(value.data)) + .orDefault(0), + walletId: this._walletSerializer.getId(wallet), + walletHmac: wallet.hmac, + }), + ); + + const continueTask = this._continueTaskFactory(this._api, dataStore); + const result = await continueTask.run(signPsbtCommandResult); + + if (isSuccessCommandResult(result)) { + const encodedSignatures = continueTask.getYieldedResults(); + const decodedSignatures: PsbtSignature[] = []; + // decode yielded signatures + for (const encodedSignature of encodedSignatures) { + const decodedSignature = this._decodePsbtSignature(encodedSignature); + if (decodedSignature.isLeft()) { + return CommandResultFactory({ + error: decodedSignature.extract(), + }); + } else if (decodedSignature.isRight()) { + decodedSignatures.push(decodedSignature.extract()); + } + } + return CommandResultFactory({ data: decodedSignatures }); + } + return result; + } + + /** + * Decodes a PSBT (Partially Signed Bitcoin Transaction) signature from a given byte array input. + * It determines the type of signature or data present based on input tags and delegates processing + * to the appropriate decoding method. + * + * If inputOrTag is: + * - at most 0xffff then it's a partial signature (legacy, native segwit, taproot or nested segwit) https://github.com/LedgerHQ/app-bitcoin-new/blob/24bcdae8274fa9866a11db54a713d93d5467c819/doc/bitcoin.md#if-tag_or_input_index-is-at-most-65535 + * - equal to 0xFFFFFFFF then it's a round 1 of musig2 protocol https://github.com/LedgerHQ/app-bitcoin-new/blob/24bcdae8274fa9866a11db54a713d93d5467c819/doc/bitcoin.md#if-tag_or_input_index-is-more-than-65535 + * - equal to 0xFFFFFFFE then it's a round 2 of musig2 protocol https://github.com/LedgerHQ/app-bitcoin-new/blob/24bcdae8274fa9866a11db54a713d93d5467c819/doc/bitcoin.md#if-tag_or_input_index-is-more-than-65535 + * + * @param {Uint8Array} yieldedSignature - The byte array representing the PSBT signature to decode. + * @return {Either} - Either the decoded PSBT signature or an error if decoding fails. + */ + private _decodePsbtSignature( + yieldedSignature: Uint8Array, + ): Either { + const parser = new ByteArrayParser(yieldedSignature); + const inputIndexOrTagOrError = extractVarint(parser) + .map((val) => val.value) + .toEither(new InvalidStatusWordError("Invalid input index or tag")); + if (inputIndexOrTagOrError.isLeft()) { + return inputIndexOrTagOrError; + } + const inputIndexOrTag = inputIndexOrTagOrError.unsafeCoerce(); + + if (inputIndexOrTag === MUSIG_PUBNONCE_TAG) { + return this._decodeMusigPubNonce(parser); + } else if (inputIndexOrTag === MUSIG_PARTIAL_SIGNATURE_TAG) { + return this._decodeMusigPartialSignature(parser); + } else if (inputIndexOrTag <= PARTIAL_SIGNATURE_MAX_TAG) { + return this._decodePartialSignature(parser, inputIndexOrTag); + } + return Left( + new InvalidStatusWordError( + `Invalid input index or tag returned: ${inputIndexOrTag}`, + ), + ); + } + + /** + * Decodes a Musig public nonce from the provided byte stream parser. + * + * @param {ByteArrayParser} parser - The parser used to extract data fields from a byte array. + * @return {Either} An `Either` containing either: + * - `MusigPubNonce` object if decoding is successful, or + * - `InvalidStatusWordError` if any required field is missing or invalid. + */ + private _decodeMusigPubNonce( + parser: ByteArrayParser, + ): Either { + const inputIndexOrError = extractVarint(parser) + .map((val) => val.value) + .toEither(new InvalidStatusWordError("Invalid input index")); + const pubnonceOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBNONCE_LENGTH), + ).toEither(new InvalidStatusWordError("Pubnonce is missing")); + const participantPubkeyOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBKEY_LENGTH_COMPRESSED), + ).toEither(new InvalidStatusWordError("Participant pubkey is missing")); + const aggregatedPubkeyOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBKEY_LENGTH_COMPRESSED), + ).toEither(new InvalidStatusWordError("Aggregated pubkey is missing")); + const tapleafHash = Maybe.fromNullable( + parser.extractFieldByLength(parser.getUnparsedRemainingLength()), + ).orDefault(Uint8Array.from([])); + return inputIndexOrError.chain((inputIndex) => + Either.sequence([ + pubnonceOrError, + participantPubkeyOrError, + aggregatedPubkeyOrError, + ]).map((values) => ({ + inputIndex, + pubnonce: values[0]!, + participantPubkey: values[1]!, + aggregatedPubkey: values[2]!, + tapleafHash, + })), + ); + } + + /** + * Decodes a Musig partial signature from the given byte array parser. + * This involves extracting and validating the input index, partial signature, + * participant public key, aggregated public key, and the optional tapleaf hash. + * + * @param {ByteArrayParser} parser The parser to extract the Musig partial signature data from. + * @return {Either} + * Returns an `Either` containing the decoded Musig partial signature on success or + * an `InvalidStatusWordError` if any required component is missing or invalid. + */ + private _decodeMusigPartialSignature( + parser: ByteArrayParser, + ): Either { + const inputIndexOrError = extractVarint(parser) + .map((val) => val.value) + .toEither(new InvalidStatusWordError("Invalid input index")); + const partialSignatureOrError = Maybe.fromNullable( + parser.extractFieldByLength(PARTIAL_SIGNATURE_LENGTH), + ).toEither(new InvalidStatusWordError("Partial signature is missing")); + const participantPubkeyOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBKEY_LENGTH_COMPRESSED), + ).toEither(new InvalidStatusWordError("Participant pubkey is missing")); + const aggregatedPubkeyOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBKEY_LENGTH_COMPRESSED), + ).toEither(new InvalidStatusWordError("Aggregated pubkey is missing")); + const tapleafHash = Maybe.fromNullable( + parser.extractFieldByLength(parser.getUnparsedRemainingLength()), + ).orDefault(Uint8Array.from([])); + return inputIndexOrError.chain((inputIndex) => { + return Either.sequence([ + partialSignatureOrError, + participantPubkeyOrError, + aggregatedPubkeyOrError, + ]).map((values) => ({ + inputIndex, + partialSignature: values[0]!, + participantPubkey: values[1]!, + aggregatedPubkey: values[2]!, + tapleafHash, + })); + }); + } + + /** + * Decodes a partial signature from the provided parser, extracting the public key and signature data + * and validating their lengths based on the transaction type. + * + * @param {ByteArrayParser} parser - The parser instance used to extract data fields. + * @param {number} inputIndex - The index of the transaction input associated with the signature. + * @return {Either} Either an error if the decoding fails or the decoded partial signature object containing the input index, signature, public key, and optionally the tapleaf hash. + */ + private _decodePartialSignature( + parser: ByteArrayParser, + inputIndex: number, + ): Either { + const pubkeyOrError = Maybe.fromNullable(parser.extract8BitUInt()) + .toEither(new InvalidStatusWordError("Pubkey length is missing")) + .chain((pubKeyAugmentedLength) => { + return Maybe.fromNullable( + parser.extractFieldByLength(pubKeyAugmentedLength), + ).toEither(new InvalidStatusWordError("Pubkey is missing")); + }); + const signatureOrError = Maybe.fromNullable( + parser.extractFieldByLength(parser.getUnparsedRemainingLength()), + ).toEither(new InvalidStatusWordError("Signature is missing")); + return Either.sequence([pubkeyOrError, signatureOrError]).chain( + (values) => { + const pubkey = values[0]!; + const signature = values[1]!; + if (pubkey.length === PUBKEY_LENGTH_TAPLEAF) { + // tapscript spend: pubkey_augm is the concatenation of: + // - a 32-byte x-only pubkey + // - the 32-byte tapleaf_hash + return Right({ + inputIndex, + signature, + pubkey: pubkey.slice(0, PUBKEY_LENGTH), + tapleafHash: pubkey.slice(PUBKEY_LENGTH), + }); + } else if ( + [PUBKEY_LENGTH, PUBKEY_LENGTH_COMPRESSED].includes(pubkey.length) + ) { + // either legacy, segwit or taproot keypath spend + // pubkey must be 32 (taproot x-only pubkey) or 33 bytes (compressed pubkey) + return Right({ + inputIndex, + signature, + pubkey, + }); + } + return Left( + new InvalidStatusWordError( + `Invalid pubkey length returned: ${pubkey.length}`, + ), + ); + }, + ); + } +} diff --git a/packages/signer/signer-btc/src/internal/di.ts b/packages/signer/signer-btc/src/internal/di.ts index 8e29dd4d5..94d38bc80 100644 --- a/packages/signer/signer-btc/src/internal/di.ts +++ b/packages/signer/signer-btc/src/internal/di.ts @@ -4,8 +4,12 @@ import { } from "@ledgerhq/device-management-kit"; import { Container } from "inversify"; +import { dataStoreModuleFactory } from "@internal/data-store/di/dataStoreModule"; import { externalTypes } from "@internal/externalTypes"; +import { merkleTreeModuleFactory } from "@internal/merkle-tree/di/merkleTreeModule"; +import { psbtModuleFactory } from "@internal/psbt/di/psbtModule"; import { useCasesModuleFactory } from "@internal/use-cases/di/useCasesModule"; +import { walletModuleFactory } from "@internal/wallet/di/walletModule"; import { appBinderModuleFactory } from "./app-binder/di/appBinderModule"; @@ -21,7 +25,14 @@ export const makeContainer = ({ dmk, sessionId }: MakeContainerProps) => { .bind(externalTypes.SessionId) .toConstantValue(sessionId); - container.load(appBinderModuleFactory(), useCasesModuleFactory()); + container.load( + appBinderModuleFactory(), + useCasesModuleFactory(), + walletModuleFactory(), + psbtModuleFactory(), + dataStoreModuleFactory(), + merkleTreeModuleFactory(), + ); return container; }; diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts index 666c669b0..b66e24168 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesModule.ts @@ -3,6 +3,7 @@ import { ContainerModule } from "inversify"; import { useCasesTypes } from "@internal/use-cases/di/useCasesTypes"; import { GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase"; import { SignMessageUseCase } from "@internal/use-cases/sign-message/SignMessageUseCase"; +import { SignPsbtUseCase } from "@internal/use-cases/sign-psbt/SignPsbtUseCase"; export const useCasesModuleFactory = () => new ContainerModule( @@ -19,5 +20,6 @@ export const useCasesModuleFactory = () => GetExtendedPublicKeyUseCase, ); bind(useCasesTypes.SignMessageUseCase).to(SignMessageUseCase); + bind(useCasesTypes.SignPsbtUseCase).to(SignPsbtUseCase); }, ); diff --git a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts index 2b00e636e..e2ebd8142 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/di/useCasesTypes.ts @@ -1,4 +1,5 @@ export const useCasesTypes = { GetExtendedPublicKeyUseCase: Symbol.for("GetExtendedPublicKeyUseCase"), SignMessageUseCase: Symbol.for("SignMessageUseCase"), + SignPsbtUseCase: Symbol.for("SignPsbtUseCase"), }; diff --git a/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts b/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts index 44caf4bbf..d78720ae8 100644 --- a/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts +++ b/packages/signer/signer-btc/src/internal/use-cases/sign-message/SignMessageUseCase.ts @@ -1,6 +1,6 @@ import { inject, injectable } from "inversify"; -import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionType"; +import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; diff --git a/packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.test.ts b/packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.test.ts new file mode 100644 index 000000000..a6bcc80b6 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.test.ts @@ -0,0 +1,30 @@ +import { DefaultDescriptorTemplate, DefaultWallet } from "@api/model/Wallet"; +import { type BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; + +import { SignPsbtUseCase } from "./SignPsbtUseCase"; + +describe("SignPsbtUseCase", () => { + it("should call signPsbt on appBinder with the correct arguments", () => { + // Given + const wallet = new DefaultWallet( + "84'/0'/0'", + DefaultDescriptorTemplate.NATIVE_SEGWIT, + ); + const psbt = "some-psbt"; + const appBinder = { + signPsbt: jest.fn(), + }; + const signPsbtUseCase = new SignPsbtUseCase( + appBinder as unknown as BtcAppBinder, + ); + + // When + signPsbtUseCase.execute(wallet, psbt); + + // Then + expect(appBinder.signPsbt).toHaveBeenCalledWith({ + wallet, + psbt, + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.ts b/packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.ts new file mode 100644 index 000000000..b95998051 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.ts @@ -0,0 +1,26 @@ +import { inject, injectable } from "inversify"; + +import { SignPsbtDAReturnType } from "@api/app-binder/SignPsbtDeviceActionTypes"; +import { Psbt } from "@api/model/Psbt"; +import { Wallet } from "@api/model/Wallet"; +import { BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; +import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; + +@injectable() +export class SignPsbtUseCase { + private _appBinder: BtcAppBinder; + + constructor( + @inject(appBinderTypes.AppBinder) + appBinding: BtcAppBinder, + ) { + this._appBinder = appBinding; + } + + execute(wallet: Wallet, psbt: Psbt): SignPsbtDAReturnType { + return this._appBinder.signPsbt({ + wallet, + psbt, + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.test.ts b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.test.ts new file mode 100644 index 000000000..d29fcefb4 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.test.ts @@ -0,0 +1,156 @@ +import { + ApduResponse, + CommandResultFactory, + type CommandSuccessResult, + InvalidStatusWordError, +} from "@ledgerhq/device-management-kit"; + +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; + +const SIGNATURE_V_RESPONSE = new Uint8Array([0x1b]); +const SIGNATURE_R_RESPONSE = new Uint8Array([ + 0x97, 0xa4, 0xca, 0x8f, 0x69, 0x46, 0x33, 0x59, 0x26, 0x01, 0xf5, 0xa2, 0x3e, + 0x0b, 0xcc, 0x55, 0x3c, 0x9d, 0x0a, 0x90, 0xd3, 0xa3, 0x42, 0x2d, 0x57, 0x55, + 0x08, 0xa9, 0x28, 0x98, 0xb9, 0x6e, +]); +const SIGNATURE_S_RESPONSE = new Uint8Array([ + 0x69, 0x50, 0xd0, 0x2e, 0x74, 0xe9, 0xc1, 0x02, 0xc1, 0x64, 0xa2, 0x25, 0x53, + 0x30, 0x82, 0xca, 0xbd, 0xd8, 0x90, 0xef, 0xc4, 0x63, 0xf6, 0x7f, 0x60, 0xce, + 0xfe, 0x8c, 0x3f, 0x87, 0xcf, 0xce, +]); + +const SIGNATURE_RESPONSE = new Uint8Array([ + ...SIGNATURE_V_RESPONSE, + ...SIGNATURE_R_RESPONSE, + ...SIGNATURE_S_RESPONSE, +]); + +describe("BtcCommandUtils", () => { + describe("isSuccessResponse", () => { + it("should return true if statusCode is e000", () => { + // given + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isSuccessResponse(apduResponse); + // then + expect(result).toBe(true); + }); + it("should return true if statusCode is 9000", () => { + // given + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isSuccessResponse(apduResponse); + // then + expect(result).toBe(true); + }); + it("should return false if statusCode is not allowed", () => { + // given + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x43, 0x04]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isSuccessResponse(apduResponse); + // then + expect(result).toBe(false); + }); + }); + describe("isContinueResponse", () => { + it("should return true if statusCode is e000", () => { + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0xe0, 0x00]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isContinueResponse(apduResponse); + // then + expect(result).toBe(true); + }); + it("should return false if statusCode is 9000", () => { + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }); + // when + const result = BtcCommandUtils.isContinueResponse(apduResponse); + // then + expect(result).toBe(false); + }); + }); + describe("getSignature", () => { + it("should return an error if 'v' is missing", () => { + // given + const result = CommandResultFactory({ + data: new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: new Uint8Array([]), + }), + }); + + // when + const signature = BtcCommandUtils.getSignature( + result as CommandSuccessResult, + ); + + // then + expect(signature).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("V is missing"), + }), + ); + }); + + it("should return an error if 's' is missing", () => { + // given + const result = CommandResultFactory({ + data: new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: new Uint8Array([ + ...SIGNATURE_V_RESPONSE, + ...SIGNATURE_R_RESPONSE, + ]), + }), + }); + // when + const signature = BtcCommandUtils.getSignature( + result as CommandSuccessResult, + ); + // then + expect(signature).toStrictEqual( + CommandResultFactory({ + error: new InvalidStatusWordError("S is missing"), + }), + ); + }); + + it("should return a signature if v, r, and s are present", () => { + // given + const result = CommandResultFactory({ + data: new ApduResponse({ + statusCode: new Uint8Array([0x90, 0x00]), + data: SIGNATURE_RESPONSE, + }), + }); + // when + const signature = BtcCommandUtils.getSignature( + result as CommandSuccessResult, + ); + // then + expect(signature).toStrictEqual( + CommandResultFactory({ + data: { + v: 27, + r: "0x97a4ca8f694633592601f5a23e0bcc553c9d0a90d3a3422d575508a92898b96e", + s: "0x6950d02e74e9c102c164a225533082cabdd890efc463f67f60cefe8c3f87cfce", + }, + }), + ); + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts new file mode 100644 index 000000000..c47754ed2 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/utils/BtcCommandUtils.ts @@ -0,0 +1,72 @@ +import { + ApduParser, + type ApduResponse, + type CommandResult, + CommandResultFactory, + type CommandSuccessResult, + CommandUtils as DmkCommandUtils, + InvalidStatusWordError, +} from "@ledgerhq/device-management-kit"; + +import { type Signature } from "@api/model/Signature"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { SW_INTERRUPTED_EXECUTION } from "@internal/app-binder/command/utils/constants"; + +const R_LENGTH = 32; +const S_LENGTH = 32; + +export class BtcCommandUtils { + static isContinueResponse(response: ApduResponse) { + return ( + response.statusCode[0] === SW_INTERRUPTED_EXECUTION[0] && + response.statusCode[1] === SW_INTERRUPTED_EXECUTION[1] + ); + } + static isSuccessResponse(response: ApduResponse) { + return ( + DmkCommandUtils.isSuccessResponse(response) || + BtcCommandUtils.isContinueResponse(response) + ); + } + + static getSignature( + result: CommandSuccessResult, + ): CommandResult { + const parser = new ApduParser(result.data); + + const v = parser.extract8BitUInt(); + if (v === undefined) { + return CommandResultFactory({ + error: new InvalidStatusWordError("V is missing"), + }); + } + + const r = parser.encodeToHexaString( + parser.extractFieldByLength(R_LENGTH), + true, + ); + if (!r) { + return CommandResultFactory({ + error: new InvalidStatusWordError("R is missing"), + }); + } + + const s = parser.encodeToHexaString( + parser.extractFieldByLength(S_LENGTH), + true, + ); + if (!s) { + return CommandResultFactory({ + error: new InvalidStatusWordError("S is missing"), + }); + } + + return CommandResultFactory({ + data: { + v, + r, + s, + }, + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/utils/CommandUtils.test.ts b/packages/signer/signer-btc/src/internal/utils/CommandUtils.test.ts deleted file mode 100644 index e938e493c..000000000 --- a/packages/signer/signer-btc/src/internal/utils/CommandUtils.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ApduResponse } from "@ledgerhq/device-management-kit"; - -import { CommandUtils } from "@internal/utils/CommandUtils"; - -describe("CommandUtils", () => { - describe("isSuccessResponse", () => { - it("should return true if statusCode is e000", () => { - // given - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0xe0, 0x00]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isSuccessResponse(apduResponse); - // then - expect(result).toBe(true); - }); - it("should return true if statusCode is 9000", () => { - // given - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0x90, 0x00]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isSuccessResponse(apduResponse); - // then - expect(result).toBe(true); - }); - it("should return false if statusCode is not allowed", () => { - // given - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0x43, 0x04]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isSuccessResponse(apduResponse); - // then - expect(result).toBe(false); - }); - }); - describe("isSuccessResponse", () => { - it("should return true if statusCode is e000", () => { - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0xe0, 0x00]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isContinueResponse(apduResponse); - // then - expect(result).toBe(true); - }); - it("should return false if statusCode is 9000", () => { - const apduResponse = new ApduResponse({ - statusCode: Uint8Array.from([0x90, 0x00]), - data: Uint8Array.from([]), - }); - // when - const result = CommandUtils.isContinueResponse(apduResponse); - // then - expect(result).toBe(false); - }); - }); -}); diff --git a/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts b/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts deleted file mode 100644 index 7083d7cb5..000000000 --- a/packages/signer/signer-btc/src/internal/utils/CommandUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - type ApduResponse, - CommandUtils as DmkCommandUtils, -} from "@ledgerhq/device-management-kit"; - -import { SW_INTERRUPTED_EXECUTION } from "@internal/app-binder/command/utils/constants"; - -export class CommandUtils { - static isContinueResponse(response: ApduResponse) { - return ( - response.statusCode[0] === SW_INTERRUPTED_EXECUTION[0] && - response.statusCode[1] === SW_INTERRUPTED_EXECUTION[1] - ); - } - static isSuccessResponse(response: ApduResponse) { - return ( - DmkCommandUtils.isSuccessResponse(response) || - CommandUtils.isContinueResponse(response) - ); - } -} diff --git a/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.test.ts b/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.test.ts index 67db87533..f3a9bf987 100644 --- a/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.test.ts +++ b/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.test.ts @@ -57,7 +57,7 @@ describe("DefaultWalletBuilder tests", () => { // Given const builder = new DefaultWalletBuilder(mockMerkleTree); const defaultWallet = new DefaultWallet( - "/48'/1'/0'/0'", + "48'/1'/0'/0'", DefaultDescriptorTemplate.NATIVE_SEGWIT, ); const masterFingerprint = hexaStringToBuffer("5c9e228d")!; diff --git a/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.ts b/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.ts index 2c587c850..004c759d6 100644 --- a/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.ts +++ b/packages/signer/signer-btc/src/internal/wallet/service/DefaultWalletBuilder.ts @@ -39,7 +39,7 @@ export class DefaultWalletBuilder implements WalletBuilder { // For internal keys, the xpub should be put after key origin informations // https://github.com/LedgerHQ/app-bitcoin-new/blob/master/doc/wallet.md#keys-information-vector const hexMasterFingerprint = bufferToHexaString(masterFingerprint).slice(2); - const keyOrigin = `[${hexMasterFingerprint}${wallet.derivationPath}]`; + const keyOrigin = `[${hexMasterFingerprint}/${wallet.derivationPath}]`; const key = `${keyOrigin}${extendedPublicKey}`; // Empty name for default wallets const name = ""; diff --git a/packages/signer/signer-utils/src/index.ts b/packages/signer/signer-utils/src/index.ts index a83fa9315..69f7253b8 100644 --- a/packages/signer/signer-utils/src/index.ts +++ b/packages/signer/signer-utils/src/index.ts @@ -1 +1,2 @@ +export * from "./utils/CommandErrorHelper"; export * from "./utils/DerivationPathUtils"; diff --git a/packages/signer/signer-utils/src/utils/CommandErrorHelper.test.ts b/packages/signer/signer-utils/src/utils/CommandErrorHelper.test.ts new file mode 100644 index 000000000..4e85ca302 --- /dev/null +++ b/packages/signer/signer-utils/src/utils/CommandErrorHelper.test.ts @@ -0,0 +1,62 @@ +import { + ApduResponse, + CommandResultFactory, + GlobalCommandErrorHandler, +} from "@ledgerhq/device-management-kit"; + +import { CommandErrorHelper } from "./CommandErrorHelper"; + +describe("CommandErrorHelper", () => { + it("should return the correct error args and call factory when error is found", () => { + // given + const errors = { + "4224": { message: "An error occurred" }, + }; + const errorFactory = jest.fn(); + const helper = new CommandErrorHelper(errors, errorFactory); + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x42, 0x24]), + data: Uint8Array.from([]), + }); + // when + helper.getError(apduResponse); + // then + expect(errorFactory).toHaveBeenNthCalledWith(1, { + ...errors["4224"], + errorCode: "4224", + }); + }); + it("should return a global error when no error is found", () => { + // given + const errors = {}; + const errorFactory = jest.fn(); + const helper = new CommandErrorHelper(errors, errorFactory); + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x55, 0x15]), + data: Uint8Array.from([]), + }); + // when + const error = helper.getError(apduResponse); + // then + expect(errorFactory).toHaveBeenCalledTimes(0); + expect(error).toStrictEqual( + CommandResultFactory({ + error: GlobalCommandErrorHandler.handle(apduResponse), + }), + ); + }); + it("should return undefined if success apdu response", () => { + // given + const errors = {}; + const errorFactory = jest.fn(); + const helper = new CommandErrorHelper(errors, errorFactory); + const apduResponse = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: Uint8Array.from([]), + }); + // when + const error = helper.getError(apduResponse); + // then + expect(error).toBeUndefined(); + }); +}); diff --git a/packages/signer/signer-utils/src/utils/CommandErrorHelper.ts b/packages/signer/signer-utils/src/utils/CommandErrorHelper.ts new file mode 100644 index 000000000..dff95b60b --- /dev/null +++ b/packages/signer/signer-utils/src/utils/CommandErrorHelper.ts @@ -0,0 +1,44 @@ +import { + ApduParser, + type ApduResponse, + type CommandErrorArgs, + type CommandErrors, + type CommandResult, + CommandResultFactory, + CommandUtils, + type DeviceExchangeError, + GlobalCommandErrorHandler, + isCommandErrorCode, +} from "@ledgerhq/device-management-kit"; + +export class CommandErrorHelper { + constructor( + private readonly _errors: CommandErrors, + private readonly _errorFactory: ( + args: CommandErrorArgs, + ) => DeviceExchangeError, + private readonly _isSuccessResponse = CommandUtils.isSuccessResponse, + ) {} + + getError( + apduResponse: ApduResponse, + ): CommandResult | undefined { + const apduParser = new ApduParser(apduResponse); + const errorCode = apduParser.encodeToHexaString(apduResponse.statusCode); + + if (isCommandErrorCode(errorCode, this._errors)) { + return CommandResultFactory({ + error: this._errorFactory({ + ...this._errors[errorCode], + errorCode, + }), + }); + } + if (!this._isSuccessResponse(apduResponse)) { + return CommandResultFactory({ + error: GlobalCommandErrorHandler.handle(apduResponse), + }); + } + return undefined; + } +}