From 1b61addf0b7927d0803cc556a371b5cfe010b3c6 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 27 Dec 2024 18:38:59 +0100 Subject: [PATCH] :sparkles: (signer-btc): Create SignPsbt DA & task & s --- .changeset/nervous-points-judge.md | 5 + .../signer/signer-btc/src/api/SignerBtc.ts | 17 +- .../app-binder/SignPsbtDeviceActionTypes.ts | 46 +++ packages/signer/signer-btc/src/api/index.ts | 5 +- .../src/internal/DefaultSignerBtc.ts | 11 +- .../src/internal/app-binder/BtcAppBinder.ts | 22 +- .../SignPsbt/SignPsbtDeviceAction.test.ts | 369 ++++++++++++++++++ .../SignPsbt/SignPsbtDeviceAction.ts | 216 ++++++++++ .../internal/app-binder/di/appBinderModule.ts | 2 + .../internal/app-binder/di/appBinderTypes.ts | 1 + .../app-binder/task/BuildPsbtTask.test.ts | 94 +++++ .../internal/app-binder/task/BuildPsbtTask.ts | 92 +++++ .../task/PrepareWalletPolicyTask.test.ts | 186 +++++++++ .../task/PrepareWalletPolicyTask.ts | 78 ++++ .../app-binder/task/SignPsbtTask.test.ts | 0 .../internal/app-binder/task/SignPsbtTask.ts | 87 +++++ .../internal/use-cases/di/useCasesModule.ts | 2 + .../internal/use-cases/di/useCasesTypes.ts | 1 + .../use-cases/sign-psbt/SignPsbtUseCase.ts | 26 ++ 19 files changed, 1244 insertions(+), 16 deletions(-) create mode 100644 .changeset/nervous-points-judge.md create mode 100644 packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts create mode 100644 packages/signer/signer-btc/src/internal/use-cases/sign-psbt/SignPsbtUseCase.ts 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/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/SignPsbtDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts new file mode 100644 index 000000000..8c928a3ac --- /dev/null +++ b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts @@ -0,0 +1,46 @@ +import { + type CommandErrorResult, + type DeviceActionState, + type ExecuteDeviceActionReturnType, + type OpenAppDAError, + type OpenAppDARequiredInteraction, +} from "@ledgerhq/device-management-kit"; + +import { type Psbt } from "@api/model/Psbt"; +import { type Signature } from "@api/model/Signature"; +import { type Wallet } from "@api/model/Wallet"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; + +export type SignPsbtDAOutput = Signature; + +export type SignPsbtDAInput = { + psbt: Psbt; + wallet: Wallet; +}; + +export type SignPsbtDAError = + | OpenAppDAError + | CommandErrorResult["error"]; + +type SignPsbtDARequiredInteraction = OpenAppDARequiredInteraction; + +export type SignPsbtDAIntermediateValue = { + requiredUserInteraction: SignPsbtDARequiredInteraction; +}; + +export type SignPsbtDAState = DeviceActionState< + SignPsbtDAOutput, + SignPsbtDAError, + SignPsbtDAIntermediateValue +>; + +export type SignPsbtDAInternalState = { + readonly error: SignPsbtDAError | null; + readonly signature: Signature | 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/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.ts b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts index 795a1da93..ef70b3fed 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts @@ -8,10 +8,14 @@ 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 { externalTypes } from "@internal/externalTypes"; import { SignMessageDeviceAction } from "./device-action/SignMessage/SignMessageDeviceAction"; @@ -25,7 +29,7 @@ export class BtcAppBinder { getExtendedPublicKey( args: GetExtendedPublicKeyDAInput, - ): GetExtendedPublicKeyReturnType { + ): GetExtendedPublicKeyDAReturnType { return this.dmk.executeDeviceAction({ sessionId: this.sessionId, deviceAction: new SendCommandInAppDeviceAction({ @@ -54,4 +58,16 @@ export class BtcAppBinder { }), }); } + + signPsbt(args: { psbt: Psbt; wallet: Wallet }): SignPsbtDAReturnType { + return this.dmk.executeDeviceAction({ + sessionId: this.sessionId, + deviceAction: new SignPsbtDeviceAction({ + input: { + psbt: args.psbt, + wallet: args.wallet, + }, + }), + }); + } } 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..d199d99a5 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts @@ -0,0 +1,369 @@ +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 { 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 signPersonalPsbtMock = jest.fn(); + + function extractDependenciesMock() { + return { + signPsbt: signPersonalPsbtMock, + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("Success case", () => { + it("should call external dependencies with the correct parameters", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + data: { + v: 0x1c, + r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788", + s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513", + }, + }), + ); + + // Expected intermediate values for the following state sequence: + // Initial -> OpenApp -> BuildContext -> ProvideContext -> SignTypedData + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.ConfirmOpenApp, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + output: { + v: 0x1c, + r: "0x8a540510e13b0f2b11a451275716d29e08caad07e89a1c84964782fb5e1ad788", + s: "0x64a0de235b270fbe81e8e40688f4a9f9ad9d283d690552c9331d7773ceafa513", + }, + status: DeviceActionStatus.Completed, + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + + // Verify mocks calls parameters + observable.subscribe({ + complete: () => { + expect(signPersonalPsbtMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }), + ); + }, + }); + }); + }); + + describe("error cases", () => { + it("Error if the 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", + }, + }); + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if the signPsbt fails", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.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 the signPsbt throws an exception", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.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.Error, + error: new InvalidStatusWordError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + + it("Error if signPsbt return an error", (done) => { + setupOpenAppDAMock(); + + const deviceAction = new SignPsbtDeviceAction({ + input: { + wallet: {} as unknown as RegisteredWallet, + psbt: "Hello world", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.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("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", + }, + }); + + // Mock the dependencies to return some sample data + jest + .spyOn(deviceAction, "extractDependencies") + .mockReturnValue(extractDependenciesMock()); + signPersonalPsbtMock.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.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..39d929c15 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts @@ -0,0 +1,216 @@ +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 } from "@api/model/Psbt"; +import { type Signature } from "@api/model/Signature"; +import { type Wallet as ApiWallet } from "@api/model/Wallet"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; + +export type MachineDependencies = { + readonly signPsbt: (arg0: { + input: { wallet: ApiWallet; psbt: Psbt }; + }) => 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 } = 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), + 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, + signature: null, + wallet: 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: "SignPsbt", + guard: "noInternalError", + }, + "Error", + ], + }, + SignPsbt: { + invoke: { + id: "signPsbt", + src: "signPsbt", + input: ({ context }) => ({ + psbt: context.input.psbt, + wallet: context.input.wallet, + }), + onDone: { + target: "SignPsbtResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + signature: 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 }) => + context._internalState.signature + ? Right(context._internalState.signature) + : Left( + context._internalState.error || + new UnknownDAError("No error in final state"), + ), + }); + } + + extractDependencies(internalApi: InternalApi): MachineDependencies { + const signPsbt = async (arg0: { + input: { wallet: ApiWallet; psbt: Psbt }; + }): Promise> => { + return await new SignPsbtTask(internalApi, arg0.input).run(); + }; + return { + 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..30b95dc17 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.test.ts @@ -0,0 +1,94 @@ +import { + CommandResultFactory, + isSuccessCommandResult, + 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 { 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({} as InternalPsbt)), + }; + 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, + }, + psbtMapper, + dataStoreService, + ); + // when + const result = await task.run(); + // then + expect(isSuccessCommandResult(result)).toBe(true); + }); + 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, + }, + psbtMapper, + dataStoreService, + ); + // 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, + }, + psbtMapper, + dataStoreService, + ); + // 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..03dc3b521 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/BuildPsbtTask.ts @@ -0,0 +1,92 @@ +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 { 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 { DefaultKeySerializer } from "@internal/psbt/service/key/DefaultKeySerializer"; +import { DefaultKeyPairSerializer } from "@internal/psbt/service/key-pair/DefaultKeyPairSerializer"; +import { DefaultPsbtMapper } from "@internal/psbt/service/psbt/DefaultPsbtMapper"; +import { DefaultPsbtSerializer } from "@internal/psbt/service/psbt/DefaultPsbtSerializer"; +import { DefaultPsbtV2Normalizer } from "@internal/psbt/service/psbt/DefaultPsbtV2Normalizer"; +import type { PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import { DefaultValueFactory } from "@internal/psbt/service/value/DefaultValueFactory"; +import { DefaultValueParser } from "@internal/psbt/service/value/DefaultValueParser"; +import { type Wallet } from "@internal/wallet/model/Wallet"; +import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; + +type BuildPsbtTaskResponse = { + psbtCommitment: PsbtCommitment; + dataStore: DataStore; +}; + +export class BuildPsbtTask { + private readonly _dataStoreService: DataStoreService; + private readonly _psbtMapper: PsbtMapper; + + constructor( + private readonly _args: { + wallet: Wallet; + psbt: Psbt; + }, + psbtMapper?: PsbtMapper, + dataStoreService?: DataStoreService, + ) { + const valueParser = new DefaultValueParser(); + const merkleTreeBuilder = new MerkleTreeBuilder(new Sha256HasherService()); + const merkleMapBuilder = new MerkleMapBuilder(merkleTreeBuilder); + const hasher = new Sha256HasherService(); + + this._psbtMapper = + psbtMapper || + new DefaultPsbtMapper( + new DefaultPsbtSerializer( + valueParser, + new DefaultKeyPairSerializer(new DefaultKeySerializer()), + ), + new DefaultPsbtV2Normalizer(valueParser, new DefaultValueFactory()), + ); + this._dataStoreService = + dataStoreService || + new DefaultDataStoreService( + merkleTreeBuilder, + merkleMapBuilder, + new DefaultWalletSerializer(hasher), + hasher, + ); + } + + async run(): Promise> { + const dataStore = new DataStore(); + return await EitherAsync(async ({ liftEither }) => { + // map the input PSBT (V1 or V2, string or byte array) into a normalized and parsed PSBTv2 + const 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 }, + }); + }, + }); + } +} 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..16a95159e --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/PrepareWalletPolicyTask.ts @@ -0,0 +1,78 @@ +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 { MerkleTreeBuilder } from "@internal/merkle-tree/service/MerkleTreeBuilder"; +import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; +import { type Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; +import { DefaultWalletBuilder } from "@internal/wallet/service/DefaultWalletBuilder"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; + +export type PrepareWalletPolicyTaskArgs = { wallet: ApiWallet }; + +export class PrepareWalletPolicyTask { + private readonly _walletBuilder: WalletBuilder; + constructor( + private readonly _api: InternalApi, + private readonly _args: PrepareWalletPolicyTaskArgs, + walletBuilder?: WalletBuilder, + ) { + this._walletBuilder = + walletBuilder || + new DefaultWalletBuilder( + new MerkleTreeBuilder(new Sha256HasherService()), + ); + } + + 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, + }), + ); + console.log("XPUB RESULT", xPubKeyResult); + if (!isSuccessCommandResult(xPubKeyResult)) { + return xPubKeyResult; + } + const masterFingerprintResult = await this._api.sendCommand( + new GetMasterFingerprintCommand(), + ); + console.log("MASTER FINGERPRINT RESULT", masterFingerprintResult); + 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/SignPsbtTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts new file mode 100644 index 000000000..e69de29bb 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..d78fab5b4 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts @@ -0,0 +1,87 @@ +import { + type InternalApi, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; +import { injectable } from "inversify"; + +import { Psbt } from "@api/model/Psbt"; +import { Wallet as ApiWallet } from "@api/model/Wallet"; +import { SignPsbtCommand } from "@internal/app-binder/command/SignPsbtCommand"; +import { BuildPsbtTask } from "@internal/app-binder/task/BuildPsbtTask"; +import { ContinueTask } from "@internal/app-binder/task/ContinueTask"; +import { PrepareWalletPolicyTask } from "@internal/app-binder/task/PrepareWalletPolicyTask"; +import { DataStore } from "@internal/data-store/model/DataStore"; +import { PsbtCommitment } from "@internal/data-store/service/DataStoreService"; +import { Sha256HasherService } from "@internal/merkle-tree/service/Sha256HasherService"; +import { BtcCommandUtils } from "@internal/utils/BtcCommandUtils"; +import { Wallet as InternalWallet } from "@internal/wallet/model/Wallet"; +import { DefaultWalletSerializer } from "@internal/wallet/service/DefaultWalletSerializer"; +import type { WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +export type SignPsbtTaskArgs = { + psbt: Psbt; + wallet: ApiWallet; +}; + +@injectable() +export class SignPsbtTask { + private readonly _walletSerializer: WalletSerializer; + constructor( + private readonly _api: InternalApi, + private readonly _args: SignPsbtTaskArgs, + walletSerializer?: WalletSerializer, + ) { + const hasher = new Sha256HasherService(); + this._walletSerializer = + walletSerializer || new DefaultWalletSerializer(hasher); + } + + private async runPrepareWalletPolicy() { + return new PrepareWalletPolicyTask(this._api, { + wallet: this._args.wallet, + }).run(); + } + private async runBuildPsbt(wallet: InternalWallet) { + return new BuildPsbtTask({ wallet, psbt: this._args.psbt }).run(); + } + + private async runSignPsbt( + psbtCommitment: PsbtCommitment, + dataStore: DataStore, + wallet: InternalWallet, + ) { + const signPsbtCommandResult = await this._api.sendCommand( + new SignPsbtCommand({ + globalCommitments: psbtCommitment.globalCommitment, + inputsCommitments: psbtCommitment.inputsRoot, + outputsCommitments: psbtCommitment.outputsRoot, + walletId: this._walletSerializer.getId(wallet), + walletHmac: wallet.hmac, + }), + ); + + const continueTask = new ContinueTask(this._api); + const result = await continueTask.run(dataStore, signPsbtCommandResult); + + if (isSuccessCommandResult(result)) { + return BtcCommandUtils.getSignature(result); + } + return result; + } + + async run() { + const walletResult = await this.runPrepareWalletPolicy(); + if (!isSuccessCommandResult(walletResult)) { + return walletResult; + } + const psbtResult = await this.runBuildPsbt(walletResult.data); + if (!isSuccessCommandResult(psbtResult)) { + return psbtResult; + } + return await this.runSignPsbt( + psbtResult.data.psbtCommitment, + psbtResult.data.dataStore, + walletResult.data, + ); + } +} 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-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, + }); + } +}