diff --git a/.changeset/selfish-maps-rhyme.md b/.changeset/selfish-maps-rhyme.md new file mode 100644 index 000000000..15fff291b --- /dev/null +++ b/.changeset/selfish-maps-rhyme.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-bitcoin": minor +--- + +Sign transaction API diff --git a/apps/sample/src/components/SignerBtcView/SignPsbtDAInputValusForm.tsx b/apps/sample/src/components/SignerBtcView/SignPsbtDAInputValusForm.tsx index c067bebd8..c9de8d83c 100644 --- a/apps/sample/src/components/SignerBtcView/SignPsbtDAInputValusForm.tsx +++ b/apps/sample/src/components/SignerBtcView/SignPsbtDAInputValusForm.tsx @@ -10,7 +10,7 @@ type SignPsbtInputValuesType = { descriptorTemplate: DefaultDescriptorTemplate; }; -const descriptorTemplateToDerivationPath: Record< +export const descriptorTemplateToDerivationPath: Record< DefaultDescriptorTemplate, string > = { diff --git a/apps/sample/src/components/SignerBtcView/index.tsx b/apps/sample/src/components/SignerBtcView/index.tsx index 26700ddcc..c7bdbe472 100644 --- a/apps/sample/src/components/SignerBtcView/index.tsx +++ b/apps/sample/src/components/SignerBtcView/index.tsx @@ -12,11 +12,17 @@ import { type SignPsbtDAError, type SignPsbtDAIntermediateValue, type SignPsbtDAOutput, + type SignTransactionDAError, + type SignTransactionDAIntermediateValue, + type SignTransactionDAOutput, } 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 { + descriptorTemplateToDerivationPath, + SignPsbtDAInputValuesForm, +} from "@/components/SignerBtcView/SignPsbtDAInputValusForm"; import { useDmk } from "@/providers/DeviceManagementKitProvider"; const DEFAULT_DERIVATION_PATH = "84'/0'/0'"; @@ -115,6 +121,39 @@ export const SignerBtcView: React.FC<{ sessionId: string }> = ({ SignPsbtDAError, SignPsbtDAIntermediateValue >, + { + title: "Sign transaction", + description: + "Perform all the actions necessary to sign a PSBT with the device and extract transaction", + executeDeviceAction: ({ descriptorTemplate, psbt, path }) => { + if (!signer) { + throw new Error("Signer not initialized"); + } + + return signer.signTransaction( + new DefaultWallet(path, descriptorTemplate), + psbt, + ); + }, + InputValuesComponent: SignPsbtDAInputValuesForm, + initialValues: { + descriptorTemplate: DefaultDescriptorTemplate.NATIVE_SEGWIT, + psbt: "", + path: descriptorTemplateToDerivationPath[ + DefaultDescriptorTemplate.NATIVE_SEGWIT + ], + }, + deviceModelId, + } satisfies DeviceActionProps< + SignTransactionDAOutput, + { + psbt: string; + path: string; + descriptorTemplate: DefaultDescriptorTemplate; + }, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >, ], [deviceModelId, signer], ); diff --git a/packages/signer/signer-btc/package.json b/packages/signer/signer-btc/package.json index 4abe18ccc..60b87c639 100644 --- a/packages/signer/signer-btc/package.json +++ b/packages/signer/signer-btc/package.json @@ -4,7 +4,7 @@ "license": "Apache-2.0", "main": "lib/cjs/index.js", "types": "lib/cjs/index.d.ts", - "private": true, + "private": false, "exports": { ".": { "types": "./lib/types/index.d.ts", diff --git a/packages/signer/signer-btc/src/api/SignerBtc.ts b/packages/signer/signer-btc/src/api/SignerBtc.ts index c955bcd56..69a0c767b 100644 --- a/packages/signer/signer-btc/src/api/SignerBtc.ts +++ b/packages/signer/signer-btc/src/api/SignerBtc.ts @@ -1,6 +1,7 @@ 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 SignTransactionDAReturnType } from "@api/app-binder/SignTransactionDeviceActionTypes"; import { type AddressOptions } from "@api/model/AddressOptions"; import { type Psbt } from "@api/model/Psbt"; import { type Wallet } from "@api/model/Wallet"; @@ -15,6 +16,6 @@ export interface SignerBtc { message: string, ) => SignMessageDAReturnType; signPsbt: (wallet: Wallet, psbt: Psbt) => SignPsbtDAReturnType; + signTransaction: (wallet: Wallet, psbt: Psbt) => SignTransactionDAReturnType; // getAddress: (wallet: Wallet, options?: AddressOptions) => 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 index 51d62c768..af8a3e080 100644 --- a/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts +++ b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts @@ -35,7 +35,7 @@ export type SignPsbtDAError = | OpenAppDAError | CommandErrorResult["error"]; -type SignPsbtDARequiredInteraction = +export type SignPsbtDARequiredInteraction = | OpenAppDARequiredInteraction | UserInteractionRequired.SignTransaction; diff --git a/packages/signer/signer-btc/src/api/app-binder/SignTransactionDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/SignTransactionDeviceActionTypes.ts new file mode 100644 index 000000000..750c0c3b5 --- /dev/null +++ b/packages/signer/signer-btc/src/api/app-binder/SignTransactionDeviceActionTypes.ts @@ -0,0 +1,63 @@ +import { + type CommandErrorResult, + type DeviceActionState, + type ExecuteDeviceActionReturnType, + type HexaString, + type OpenAppDAError, + type OpenAppDARequiredInteraction, +} from "@ledgerhq/device-management-kit"; + +import { type SignPsbtDARequiredInteraction } 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 { type DataStoreService } 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 ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; +import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +export type SignTransactionDAOutput = HexaString; + +export type SignTransactionDAInput = { + psbt: ApiPsbt; + wallet: ApiWallet; + walletBuilder: WalletBuilder; + walletSerializer: WalletSerializer; + dataStoreService: DataStoreService; + psbtMapper: PsbtMapper; + valueParser: ValueParser; +}; + +export type SignTransactionDAError = + | OpenAppDAError + | CommandErrorResult["error"]; + +type SignTransactionDARequiredInteraction = + | OpenAppDARequiredInteraction + | SignPsbtDARequiredInteraction; + +export type SignTransactionDAIntermediateValue = { + requiredUserInteraction: SignTransactionDARequiredInteraction; +}; + +export type SignTransactionDAState = DeviceActionState< + SignTransactionDAOutput, + SignTransactionDAError, + SignTransactionDAIntermediateValue +>; + +export type SignTransactionDAInternalState = { + readonly error: SignTransactionDAError | null; + readonly signatures: PsbtSignature[] | null; + readonly signedPsbt: InternalPsbt | null; + readonly transaction: HexaString | null; +}; + +export type SignTransactionDAReturnType = ExecuteDeviceActionReturnType< + SignTransactionDAOutput, + SignTransactionDAError, + SignTransactionDAIntermediateValue +>; diff --git a/packages/signer/signer-btc/src/api/index.ts b/packages/signer/signer-btc/src/api/index.ts index a1bdfb302..d506afd5a 100644 --- a/packages/signer/signer-btc/src/api/index.ts +++ b/packages/signer/signer-btc/src/api/index.ts @@ -9,6 +9,7 @@ export type { SignMessageDAState, } from "@api/app-binder/SignMessageDeviceActionTypes"; export * from "@api/app-binder/SignPsbtDeviceActionTypes"; +export * from "@api/app-binder/SignTransactionDeviceActionTypes"; 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.test.ts b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts index 003bcb245..399e3dd3b 100644 --- a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts +++ b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.test.ts @@ -1,7 +1,10 @@ import { type DeviceManagementKit } from "@ledgerhq/device-management-kit"; +import { DefaultDescriptorTemplate, DefaultWallet } from "@api/model/Wallet"; import { DefaultSignerBtc } from "@internal/DefaultSignerBtc"; import { GetExtendedPublicKeyUseCase } from "@internal/use-cases/get-extended-public-key/GetExtendedPublicKeyUseCase"; +import { SignPsbtUseCase } from "@internal/use-cases/sign-psbt/SignPsbtUseCase"; +import { SignTransactionUseCase } from "@internal/use-cases/sign-transaction/SignTransactionUseCase"; import { SignMessageUseCase } from "./use-cases/sign-message/SignMessageUseCase"; @@ -39,4 +42,30 @@ describe("DefaultSignerBtc", () => { signer.signMessage(derivationPath, message); expect(SignMessageUseCase.prototype.execute).toHaveBeenCalled(); }); + it("should call signPsbtUseCase", () => { + jest.spyOn(SignPsbtUseCase.prototype, "execute"); + const sessionId = "session-id"; + const dmk = { + executeDeviceAction: jest.fn(), + } as unknown as DeviceManagementKit; + const signer = new DefaultSignerBtc({ dmk, sessionId }); + signer.signPsbt( + new DefaultWallet("44'/0'/0'", DefaultDescriptorTemplate.NATIVE_SEGWIT), + "", + ); + expect(SignPsbtUseCase.prototype.execute).toHaveBeenCalled(); + }); + it("should call signTransactionUseCase", () => { + jest.spyOn(SignTransactionUseCase.prototype, "execute"); + const sessionId = "session-id"; + const dmk = { + executeDeviceAction: jest.fn(), + } as unknown as DeviceManagementKit; + const signer = new DefaultSignerBtc({ dmk, sessionId }); + signer.signTransaction( + new DefaultWallet("44'/0'/0'", DefaultDescriptorTemplate.NATIVE_SEGWIT), + "", + ); + expect(SignTransactionUseCase.prototype.execute).toHaveBeenCalled(); + }); }); diff --git a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts index 8d48d8121..62b95cce1 100644 --- a/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts +++ b/packages/signer/signer-btc/src/internal/DefaultSignerBtc.ts @@ -12,6 +12,7 @@ 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 SignTransactionUseCase } from "@internal/use-cases/sign-transaction/SignTransactionUseCase"; import { type SignMessageUseCase } from "./use-cases/sign-message/SignMessageUseCase"; import { makeContainer } from "./di"; @@ -53,4 +54,10 @@ export class DefaultSignerBtc implements SignerBtc { .get(useCasesTypes.SignMessageUseCase) .execute(_derivationPath, _message); } + + signTransaction(wallet: Wallet, psbt: Psbt) { + return this._container + .get(useCasesTypes.SignTransactionUseCase) + .execute(wallet, psbt); + } } 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 978a50866..20795ab4f 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/BtcAppBinder.ts @@ -12,10 +12,12 @@ import { } from "@api/app-binder/GetExtendedPublicKeyDeviceActionTypes"; import { SignMessageDAReturnType } from "@api/app-binder/SignMessageDeviceActionTypes"; import { SignPsbtDAReturnType } from "@api/app-binder/SignPsbtDeviceActionTypes"; +import { SignTransactionDAReturnType } from "@api/app-binder/SignTransactionDeviceActionTypes"; 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 { SignTransactionDeviceAction } from "@internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction"; import { dataStoreTypes } from "@internal/data-store/di/dataStoreTypes"; import type { DataStoreService } from "@internal/data-store/service/DataStoreService"; import { externalTypes } from "@internal/externalTypes"; @@ -96,4 +98,24 @@ export class BtcAppBinder { }), }); } + + signTransaction(args: { + psbt: Psbt; + wallet: Wallet; + }): SignTransactionDAReturnType { + return this._dmk.executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: new SignTransactionDeviceAction({ + 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/device-action/SignTransaction/SignTransactionDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.test.ts new file mode 100644 index 000000000..54ab9ea1e --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.test.ts @@ -0,0 +1,324 @@ +import { + CommandResultFactory, + DeviceActionStatus, + UnknownDeviceExchangeError, + UserInteractionRequired, +} from "@ledgerhq/device-management-kit"; + +import { type SignTransactionDAState } from "@api/app-binder/SignTransactionDeviceActionTypes"; +import { type RegisteredWallet } from "@api/model/Wallet"; +import { makeDeviceActionInternalApiMock } from "@internal/app-binder/device-action/__test-utils__/makeInternalApi"; +import { setupSignPsbtDAMock } from "@internal/app-binder/device-action/__test-utils__/setupSignPsbtDAMock"; +import { testDeviceActionStates } from "@internal/app-binder/device-action/__test-utils__/testDeviceActionStates"; +import { type DataStoreService } 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 ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { type WalletBuilder } from "@internal/wallet/service/WalletBuilder"; +import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; + +import { SignTransactionDeviceAction } from "./SignTransactionDeviceAction"; + +jest.mock( + "@internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction", + () => ({ + ...jest.requireActual( + "@internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction", + ), + SignPsbtDeviceAction: jest.fn(() => ({ + makeStateMachine: jest.fn(), + })), + }), +); + +describe("SignTransactionDeviceAction", () => { + const updatePsbtMock = jest.fn(); + const extractTransactionMock = jest.fn(); + + function extractDependenciesMock() { + return { + updatePsbt: updatePsbtMock, + extractTransaction: extractTransactionMock, + }; + } + + describe("Success case", () => { + it("should call external dependencies with the correct parameters", (done) => { + setupSignPsbtDAMock([ + { + inputIndex: 0, + pubkey: Uint8Array.from([0x04, 0x05, 0x06]), + signature: Uint8Array.from([0x01, 0x02, 0x03]), + }, + ]); + + const deviceAction = new SignTransactionDeviceAction({ + 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()); + updatePsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + data: "Psbt" as unknown as InternalPsbt, + }), + ); + extractTransactionMock.mockResolvedValueOnce( + CommandResultFactory({ + data: "0x42", + }), + ); + + // Expected intermediate values for the following state sequence: + const expectedStates: Array = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + status: DeviceActionStatus.Pending, + }, + { + output: "0x42", + status: DeviceActionStatus.Completed, + }, + ]; + + const { observable } = testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + + // @todo Put this in a onDone handle of testDeviceActionStates + observable.subscribe({ + complete: () => { + expect(updatePsbtMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + psbt: "Hello world", + psbtMapper: "PsbtMapper", + signatures: [ + { + inputIndex: 0, + pubkey: Uint8Array.from([0x04, 0x05, 0x06]), + signature: Uint8Array.from([0x01, 0x02, 0x03]), + }, + ], + valueParser: "ValueParser", + }, + }), + ); + expect(extractTransactionMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + psbt: "Psbt", + valueParser: "ValueParser", + }, + }), + ); + }, + }); + }); + }); + + describe("error cases", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it("Error if sign psbt fails", (done) => { + setupSignPsbtDAMock([], new UnknownDeviceExchangeError("Mocked error")); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + const deviceAction = new SignTransactionDeviceAction({ + 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 update psbt fails", (done) => { + setupSignPsbtDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + 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()); + updatePsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Mocked error"), + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Error, + error: new UnknownDeviceExchangeError("Mocked error"), + }, + ]; + + testDeviceActionStates( + deviceAction, + expectedStates, + makeDeviceActionInternalApiMock(), + done, + ); + }); + it("Error if extract transaction fails", (done) => { + setupSignPsbtDAMock(); + + const deviceAction = new SignTransactionDeviceAction({ + 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()); + updatePsbtMock.mockResolvedValueOnce( + CommandResultFactory({ + data: "Psbt" as unknown as InternalPsbt, + }), + ); + extractTransactionMock.mockResolvedValueOnce( + CommandResultFactory({ + error: new UnknownDeviceExchangeError("Mocked error"), + }), + ); + + const expectedStates: Array = [ + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }, + { + status: DeviceActionStatus.Pending, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + }, + }, + { + 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, + ); + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.ts new file mode 100644 index 000000000..708bc4674 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.ts @@ -0,0 +1,302 @@ +import { + type CommandResult, + type DeviceActionStateMachine, + type HexaString, + type InternalApi, + isSuccessCommandResult, + type StateMachineTypes, + UnknownDAError, + UserInteractionRequired, + XStateDeviceAction, +} from "@ledgerhq/device-management-kit"; +import { Left, Right } from "purify-ts"; +import { assign, fromPromise, setup } from "xstate"; + +import { + type SignTransactionDAError, + type SignTransactionDAInput, + type SignTransactionDAIntermediateValue, + type SignTransactionDAInternalState, + type SignTransactionDAOutput, +} from "@api/app-binder/SignTransactionDeviceActionTypes"; +import { type Psbt as ApiPsbt } from "@api/model/Psbt"; +import { type PsbtSignature } from "@api/model/Signature"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { SignPsbtDeviceAction } from "@internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction"; +import { ExtractTransactionTask } from "@internal/app-binder/task/ExtractTransactionTask"; +import { UpdatePsbtTask } from "@internal/app-binder/task/UpdatePsbtTask"; +import { type Psbt as InternalPsbt } from "@internal/psbt/model/Psbt"; +import { type PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import type { ValueParser } from "@internal/psbt/service/value/ValueParser"; + +export type MachineDependencies = { + readonly updatePsbt: (arg0: { + input: { + psbt: ApiPsbt; + signatures: PsbtSignature[]; + psbtMapper: PsbtMapper; + valueParser: ValueParser; + }; + }) => Promise>; + readonly extractTransaction: (arg0: { + input: { psbt: InternalPsbt; valueParser: ValueParser }; + }) => Promise>; +}; + +export type ExtractMachineDependencies = ( + internalApi: InternalApi, +) => MachineDependencies; + +export class SignTransactionDeviceAction extends XStateDeviceAction< + SignTransactionDAOutput, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAInternalState +> { + constructor(args: { input: SignTransactionDAInput; inspect?: boolean }) { + super(args); + } + makeStateMachine( + internalApi: InternalApi, + ): DeviceActionStateMachine< + SignTransactionDAOutput, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAInternalState + > { + type types = StateMachineTypes< + SignTransactionDAOutput, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAInternalState + >; + + const { updatePsbt, extractTransaction } = this.extractDependencies(); + + return setup({ + types: { + input: {} as types["input"], + context: {} as types["context"], + output: {} as types["output"], + }, + + actors: { + signPsbtStateMachine: new SignPsbtDeviceAction({ + input: this.input, + }).makeStateMachine(internalApi), + updatePsbt: fromPromise(updatePsbt), + extractTransaction: fromPromise(extractTransaction), + }, + 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: "SignPsbtDeviceAction", + context: ({ input }) => { + return { + input, + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + _internalState: { + error: null, + signatures: null, + signedPsbt: null, + transaction: null, + }, + }; + }, + states: { + SignPsbtDeviceAction: { + exit: assign({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + }, + }), + invoke: { + id: "signPsbtStateMachine", + src: "signPsbtStateMachine", + input: ({ context }) => context.input, + onSnapshot: { + actions: assign({ + intermediateValue: (_) => + _.event.snapshot.context.intermediateValue, + }), + }, + onDone: { + target: "SignPsbtResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + return event.output.caseOf({ + Right: (data) => ({ + ...context._internalState, + signatures: data, + }), + Left: (error) => ({ + ...context._internalState, + error, + }), + }); + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + SignPsbtResultCheck: { + always: [ + { guard: "noInternalError", target: "UpdatePsbt" }, + { target: "Error" }, + ], + }, + UpdatePsbt: { + invoke: { + id: "updatePsbt", + src: "updatePsbt", + input: ({ context }) => ({ + psbt: context.input.psbt, + valueParser: context.input.valueParser, + psbtMapper: context.input.psbtMapper, + signatures: context._internalState.signatures!, + }), + onDone: { + target: "UpdatePsbtResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + signedPsbt: event.output.data, + }; + } else { + return { + ...context._internalState, + error: event.output.error, + }; + } + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + UpdatePsbtResultCheck: { + always: [ + { guard: "noInternalError", target: "ExtractTransaction" }, + { target: "Error" }, + ], + }, + ExtractTransaction: { + invoke: { + id: "extractTransaction", + src: "extractTransaction", + input: ({ context }) => ({ + valueParser: context.input.valueParser, + psbt: context._internalState.signedPsbt!, + }), + onDone: { + target: "ExtractTransactionResultCheck", + actions: [ + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + transaction: event.output.data, + }; + } else { + return { + ...context._internalState, + error: event.output.error, + }; + } + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + ExtractTransactionResultCheck: { + always: [ + { guard: "noInternalError", target: "Success" }, + { target: "Error" }, + ], + }, + Success: { + type: "final", + }, + Error: { + type: "final", + }, + }, + output: ({ context }) => + context._internalState.transaction + ? Right(context._internalState.transaction) + : Left( + context._internalState.error || + new UnknownDAError("No error in final state"), + ), + }); + } + + extractDependencies(): MachineDependencies { + const updatePsbt = async (arg0: { + input: { + psbt: ApiPsbt; + signatures: PsbtSignature[]; + psbtMapper: PsbtMapper; + valueParser: ValueParser; + }; + }): Promise> => { + const { + input: { psbt, signatures, valueParser, psbtMapper }, + } = arg0; + return await new UpdatePsbtTask( + { psbt, signatures }, + valueParser, + psbtMapper, + ).run(); + }; + + const extractTransaction = async (arg0: { + input: { psbt: InternalPsbt; valueParser: ValueParser }; + }): Promise> => { + const { + input: { psbt, valueParser }, + } = arg0; + return new ExtractTransactionTask({ psbt }, valueParser).run(); + }; + + return { + updatePsbt, + extractTransaction, + }; + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/setupSignPsbtDAMock.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/setupSignPsbtDAMock.ts new file mode 100644 index 000000000..64e5e613e --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/setupSignPsbtDAMock.ts @@ -0,0 +1,37 @@ +import { UserInteractionRequired } from "@ledgerhq/device-management-kit"; +import { Left, Right } from "purify-ts"; +import { assign, createMachine } from "xstate"; + +import { type PsbtSignature } from "@api/model/Signature"; +import { SignPsbtDeviceAction } from "@internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction"; + +export const setupSignPsbtDAMock = ( + sigs: PsbtSignature[] = [], + error?: unknown, +) => { + // setupOpenAppDAMock(); + (SignPsbtDeviceAction as jest.Mock).mockImplementation(() => ({ + makeStateMachine: jest.fn().mockImplementation(() => + createMachine({ + initial: "pending", + states: { + pending: { + entry: assign({ + intermediateValue: { + requiredUserInteraction: + UserInteractionRequired.SignTransaction, + }, + }), + after: { + 0: "done", + }, + }, + done: { + type: "final", + }, + }, + output: () => (error ? Left(error) : Right(sigs)), + }), + ), + })); +}; diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.test.ts new file mode 100644 index 000000000..6309287e1 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.test.ts @@ -0,0 +1,84 @@ +import { CommandResultFactory } from "@ledgerhq/device-management-kit"; + +import { ExtractTransactionTask } from "@internal/app-binder/task/ExtractTransactionTask"; +import { Key } from "@internal/psbt/model/Key"; +import { Psbt, PsbtGlobal, PsbtIn, PsbtOut } from "@internal/psbt/model/Psbt"; +import { Value } from "@internal/psbt/model/Value"; +import { DefaultValueParser } from "@internal/psbt/service/value/DefaultValueParser"; + +describe("ExtractTransactionTask", () => { + it("should extract transaction from a signed psbt", () => { + // given + const psbt = new Psbt( + new Map([ + [ + new Key(PsbtGlobal.VERSION).toHexaString(), + new Value(Uint8Array.from([0x02])), + ], + [ + new Key(PsbtGlobal.INPUT_COUNT).toHexaString(), + new Value(Uint8Array.from([0x01])), + ], + [ + new Key(PsbtGlobal.OUTPUT_COUNT).toHexaString(), + new Value(Uint8Array.from([0x01])), + ], + [ + new Key(PsbtGlobal.FALLBACK_LOCKTIME).toHexaString(), + new Value(Uint8Array.from([0x09, 0x08, 0x07, 0x06, 0x05, 0x04])), + ], + ]), + [ + new Map([ + [ + new Key(PsbtIn.WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.from([0x01, 0x02, 0x03, 0x04])), + ], + [ + new Key(PsbtIn.PREVIOUS_TXID).toHexaString(), + new Value(Uint8Array.from([0x08])), + ], + [ + new Key(PsbtIn.OUTPUT_INDEX).toHexaString(), + new Value(Uint8Array.from([0x62])), + ], + [ + new Key(PsbtIn.FINAL_SCRIPTSIG).toHexaString(), + new Value(Uint8Array.from([0x93, 0x98])), + ], + [ + new Key(PsbtIn.SEQUENCE).toHexaString(), + new Value(Uint8Array.of(0x10)), + ], + [ + new Key(PsbtIn.FINAL_SCRIPTWITNESS).toHexaString(), + new Value(Uint8Array.of(0x20, 0x30)), + ], + ]), + ], + [ + new Map([ + [ + new Key(PsbtOut.AMOUNT).toHexaString(), + new Value(Uint8Array.from([0x32])), + ], + [ + new Key(PsbtOut.SCRIPT).toHexaString(), + new Value(Uint8Array.of(0x09)), + ], + ]), + ], + ); + // when + const tx = new ExtractTransactionTask( + { psbt }, + new DefaultValueParser(), + ).run(); + // then + expect(tx).toStrictEqual( + CommandResultFactory({ + data: "0x000000000001010800000000029398000000000100000000000000000109203009080706", + }), + ); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.ts new file mode 100644 index 000000000..d6c58d84a --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.ts @@ -0,0 +1,109 @@ +import { + bufferToHexaString, + ByteArrayBuilder, + type CommandResult, + CommandResultFactory, + type HexaString, +} from "@ledgerhq/device-management-kit"; + +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { + type Psbt, + PsbtGlobal, + PsbtIn, + PsbtOut, +} from "@internal/psbt/model/Psbt"; +import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { encodeVarint } from "@internal/utils/Varint"; + +type ExtractTransactionTaskArgs = { + psbt: Psbt; +}; + +export class ExtractTransactionTask { + constructor( + private readonly _args: ExtractTransactionTaskArgs, + private readonly _valueParser: ValueParser, + ) {} + + /** + * Processes a PSBT (Partially Signed Bitcoin Transaction) and constructs a finalized Bitcoin transaction. + * + * @return {CommandResult} A `CommandResult` object containing the resulting serialized transaction + * as a hexadecimal string (without the "0x" prefix) on success, or an error code (`BtcErrorCodes`) on failure. + */ + run(): CommandResult { + const { psbt } = this._args; + const transaction = new ByteArrayBuilder(); + const psbtVersion = psbt + .getGlobalValue(PsbtGlobal.VERSION) + .chain((value) => this._valueParser.getUInt32LE(value.data)) + .orDefault(0); + transaction.add32BitUIntToData(psbtVersion, false); + const isSegwit = psbt.getInputValue(0, PsbtIn.WITNESS_UTXO).isJust(); + if (isSegwit) { + transaction.addBufferToData(Uint8Array.from([0, 0x01])); + } + const inputCount = psbt + .getGlobalValue(PsbtGlobal.INPUT_COUNT) + .chain((value) => this._valueParser.getVarint(value.data)) + .orDefault(0); + + transaction.addBufferToData(encodeVarint(inputCount).extract()!); + const witnessBuffer = new ByteArrayBuilder(); + for (let i = 0; i < inputCount; i++) { + transaction.addBufferToData( + psbt + .getInputValue(i, PsbtIn.PREVIOUS_TXID) + .mapOrDefault((v) => v.data, Buffer.from([])), + ); + const outputIndex = psbt + .getInputValue(i, PsbtIn.OUTPUT_INDEX) + .chain((value) => this._valueParser.getUInt32LE(value.data)) + .orDefault(0); + transaction.add32BitUIntToData(outputIndex, false); + const scriptSig = psbt + .getInputValue(i, PsbtIn.FINAL_SCRIPTSIG) + .mapOrDefault((v) => v.data, Uint8Array.from([])); + transaction.addBufferToData(encodeVarint(scriptSig.length).extract()!); + transaction.addBufferToData(scriptSig); + const sequence = psbt + .getInputValue(i, PsbtIn.SEQUENCE) + .chain((value) => this._valueParser.getUInt32LE(value.data)) + .orDefault(0); + transaction.add32BitUIntToData(sequence, false); + if (isSegwit) { + const witness = psbt + .getInputValue(i, PsbtIn.FINAL_SCRIPTWITNESS) + .mapOrDefault((v) => v.data, Uint8Array.from([])); + witnessBuffer.addBufferToData(witness); + } + } + const outputCount = psbt + .getGlobalValue(PsbtGlobal.OUTPUT_COUNT) + .chain((value) => this._valueParser.getVarint(value.data)) + .orDefault(0); + transaction.addBufferToData(encodeVarint(outputCount).extract()!); + for (let o = 0; o < outputCount; o++) { + const amount = psbt + .getOutputValue(o, PsbtOut.AMOUNT) + .chain((value) => this._valueParser.getInt64LE(value.data)) + .orDefault(0n); + const script = psbt + .getOutputValue(o, PsbtOut.SCRIPT) + .mapOrDefault((v) => v.data, Buffer.from([])); + transaction.add64BitIntToData(amount, false); + transaction.addBufferToData(encodeVarint(script.length).extract()!); + transaction.addBufferToData(script); + } + transaction.addBufferToData(witnessBuffer.build()); + const locktime = psbt + .getGlobalValue(PsbtGlobal.FALLBACK_LOCKTIME) + .chain((v) => this._valueParser.getUInt32LE(v.data)) + .orDefault(0); + transaction.add32BitUIntToData(locktime, false); + return CommandResultFactory({ + data: bufferToHexaString(transaction.build()), + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.test.ts new file mode 100644 index 000000000..230668da3 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.test.ts @@ -0,0 +1,257 @@ +import { CommandResultFactory } from "@ledgerhq/device-management-kit"; +import { Right } from "purify-ts"; + +import { UpdatePsbtTask } from "@internal/app-binder/task/UpdatePsbtTask"; +import { Key } from "@internal/psbt/model/Key"; +import { Psbt, PsbtGlobal, PsbtIn } from "@internal/psbt/model/Psbt"; +import { Value } from "@internal/psbt/model/Value"; +import { type PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import { DefaultValueParser } from "@internal/psbt/service/value/DefaultValueParser"; + +describe("UpdatePsbtTask", () => { + it("should update taproot psbt with signatures", async () => { + // given + const schnorr = Uint8Array.from(new Array(64).fill(0x64)); + const signature = { + inputIndex: 0, + signature: schnorr, + pubkey: Uint8Array.from([0x21]), + }; + + const fakePsbt = new Psbt( + new Map([ + [ + new Key(PsbtGlobal.INPUT_COUNT).toHexaString(), + new Value(Uint8Array.of(1)), + ], + ]), + [ + new Map([ + [ + new Key( + PsbtIn.TAP_BIP32_DERIVATION, + Uint8Array.from([0x01, 0x03, 0x04, 0x11]), + ).toHexaString(), + new Value(Uint8Array.from([0x10, 0x12])), + ], + [ + new Key(PsbtIn.NON_WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.from([])), + ], + [ + new Key(PsbtIn.REDEEM_SCRIPT).toHexaString(), + new Value(Uint8Array.from([0x09, 0x99])), + ], + ]), + ], + ); + + const psbtMapperMock = { + map: jest.fn(() => Right(fakePsbt)), + } as unknown as PsbtMapper; + + // when + const result = await new UpdatePsbtTask( + { + psbt: "", + signatures: [signature], + }, + new DefaultValueParser(), + psbtMapperMock, + ).run(); + + // then + expect(result).toStrictEqual( + CommandResultFactory({ + data: new Psbt( + new Map([ + [ + new Key(PsbtGlobal.INPUT_COUNT).toHexaString(), + new Value(Uint8Array.from([1])), + ], + ]), + [ + new Map([ + [ + new Key(PsbtIn.NON_WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.from([])), + ], + [ + new Key(PsbtIn.REDEEM_SCRIPT).toHexaString(), + new Value(Uint8Array.from([0x09, 0x99])), + ], + [ + new Key(PsbtIn.FINAL_SCRIPTWITNESS).toHexaString(), + new Value(Uint8Array.from([0x01, 0x40, ...schnorr])), + ], + ]), + ], + ), + }), + ); + }); + it("should update legacy psbt with signatures", async () => { + // given + const signature = { + inputIndex: 0, + signature: Uint8Array.from([0x42]), + pubkey: Uint8Array.from([0x21]), + }; + + const fakePsbt = new Psbt( + new Map([ + [ + new Key(PsbtGlobal.INPUT_COUNT).toHexaString(), + new Value(Uint8Array.of(1)), + ], + ]), + [ + new Map([ + [ + new Key( + PsbtIn.BIP32_DERIVATION, + Uint8Array.from([0x01, 0x02]), + ).toHexaString(), + new Value(Uint8Array.from([])), + ], + [ + new Key(PsbtIn.NON_WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.from([])), + ], + [ + new Key(PsbtIn.REDEEM_SCRIPT).toHexaString(), + new Value(Uint8Array.from([0x09, 0x99])), + ], + ]), + ], + ); + + const psbtMapperMock = { + map: jest.fn(() => Right(fakePsbt)), + } as unknown as PsbtMapper; + + // when + const result = await new UpdatePsbtTask( + { + psbt: "", + signatures: [signature], + }, + new DefaultValueParser(), + psbtMapperMock, + ).run(); + + // then + expect(result).toStrictEqual( + CommandResultFactory({ + data: new Psbt( + new Map([ + [ + new Key(PsbtGlobal.INPUT_COUNT).toHexaString(), + new Value(Uint8Array.from([1])), + ], + ]), + [ + new Map([ + [ + new Key(PsbtIn.NON_WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.from([])), + ], + [ + new Key(PsbtIn.REDEEM_SCRIPT).toHexaString(), + new Value(Uint8Array.from([0x09, 0x99])), + ], + [ + new Key(PsbtIn.FINAL_SCRIPTSIG).toHexaString(), + new Value(Uint8Array.from([0x01, 0x42, 0x01, 0x21])), + ], + ]), + ], + ), + }), + ); + }); + it("should update legacy segwit psbt with signatures", async () => { + // given + const signature = { + inputIndex: 0, + signature: Uint8Array.from([0x42]), + pubkey: Uint8Array.from([0x21]), + }; + + const fakePsbt = new Psbt( + new Map([ + [ + new Key(PsbtGlobal.INPUT_COUNT).toHexaString(), + new Value(Uint8Array.of(1)), + ], + ]), + [ + new Map([ + [ + new Key( + PsbtIn.BIP32_DERIVATION, + Uint8Array.from([0x01, 0x02]), + ).toHexaString(), + new Value(Uint8Array.from([])), + ], + [ + new Key(PsbtIn.WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.from([])), + ], + [ + new Key(PsbtIn.REDEEM_SCRIPT).toHexaString(), + new Value(Uint8Array.from([0x09, 0x99])), + ], + ]), + ], + ); + + const psbtMapperMock = { + map: jest.fn(() => Right(fakePsbt)), + } as unknown as PsbtMapper; + + // when + const result = await new UpdatePsbtTask( + { + psbt: "", + signatures: [signature], + }, + new DefaultValueParser(), + psbtMapperMock, + ).run(); + + // then + expect(result).toStrictEqual( + CommandResultFactory({ + data: new Psbt( + new Map([ + [ + new Key(PsbtGlobal.INPUT_COUNT).toHexaString(), + new Value(Uint8Array.from([1])), + ], + ]), + [ + new Map([ + [ + new Key(PsbtIn.WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.from([])), + ], + [ + new Key(PsbtIn.REDEEM_SCRIPT).toHexaString(), + new Value(Uint8Array.from([0x09, 0x99])), + ], + [ + new Key(PsbtIn.FINAL_SCRIPTWITNESS).toHexaString(), + new Value(Uint8Array.from([0x02, 0x01, 0x42, 0x01, 0x21])), + ], + [ + new Key(PsbtIn.FINAL_SCRIPTSIG).toHexaString(), + new Value(Uint8Array.from([0x02, 0x09, 0x99])), + ], + ]), + ], + ), + }), + ); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.ts new file mode 100644 index 000000000..66c3a22e6 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.ts @@ -0,0 +1,363 @@ +import { + ByteArrayBuilder, + type CommandResult, + CommandResultFactory, + UnknownDeviceExchangeError, +} from "@ledgerhq/device-management-kit"; +import { Either, EitherAsync } from "purify-ts"; + +import { type Psbt as ApiPsbt } from "@api/model/Psbt"; +import { + isPartialSignature, + type PartialSignature, + type PsbtSignature, +} from "@api/model/Signature"; +import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { + type Psbt as InternalPsbt, + PsbtGlobal, + PsbtIn, +} from "@internal/psbt/model/Psbt"; +import { Value } from "@internal/psbt/model/Value"; +import { type PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; +import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; +import { encodeScriptOperations } from "@internal/utils/ScriptOperation"; +import { encodeVarint } from "@internal/utils/Varint"; + +type UpdatePsbtTaskArgs = { + psbt: ApiPsbt; + signatures: PsbtSignature[]; +}; + +const SCHORR_SIG_LENGTH = 64; +const SCHORR_SIG_LENGTH_WITH_EXTRA_BYTE = 65; + +export class UpdatePsbtTask { + constructor( + private readonly _args: UpdatePsbtTaskArgs, + private readonly _valueParser: ValueParser, + private readonly _psbtMapper: PsbtMapper, + ) {} + + /** + * Executes the process of mapping, signing, and updating a PSBT (Partially Signed Bitcoin Transaction). + * + * This method performs the following steps: + * 1. Filters and validates the given signatures as partial signatures. + * 2. Maps the API-provided PSBT to an internal PSBT format. + * 3. Signs the PSBT with the valid signatures. + * 4. Updates the PSBT with additional information. + * + * If no valid signatures are provided, it returns an error. + * + * @return {Promise>} A `CommandResult` object encapsulating either: + * - a signed and updated PSBT if the operation is successful, or + * - an error if the operation fails (e.g., no signatures provided or mapping/signing/updating fails). + */ + public async run(): Promise> { + const { psbt: apiPsbt, signatures: psbtSignatures } = this._args; + const signatures = psbtSignatures.filter((psbtSignature) => + isPartialSignature(psbtSignature), + ); + if (signatures.length === 0) { + return CommandResultFactory({ + error: new UnknownDeviceExchangeError("No signature provided"), + }); + } + return await EitherAsync(async ({ liftEither }) => { + const psbt = await liftEither(this._psbtMapper.map(apiPsbt)); + const signedPsbt = await liftEither(this.getSignedPsbt(psbt, signatures)); + return liftEither(this.getUpdatedPsbt(signedPsbt)); + }).caseOf({ + Left: (error) => { + return CommandResultFactory({ + error: new UnknownDeviceExchangeError(error), + }); + }, + Right: (data) => CommandResultFactory({ data }), + }); + } + + /** + * Signs a Partially Signed Bitcoin Transaction (PSBT) with the provided signatures. + * + * @param {InternalPsbt} psbt - The partially signed PSBT that needs to be signed. + * @param {PartialSignature[]} psbtSignatures - An array of partial signatures, each containing the signature and related index or public key. + * @return {Either} An Either instance that contains an error if signing fails, or a signed InternalPsbt if successful. + */ + private getSignedPsbt( + psbt: InternalPsbt, + psbtSignatures: PartialSignature[], + ): Either { + return Either.encase(() => { + for (const psbtSignature of psbtSignatures) { + // Note: Looking at BIP32 derivation does not work in the generic case, + // since some inputs might not have a BIP32-derived pubkey. + const pubkeys = psbt + .getInputKeyDatas(psbtSignature.inputIndex, PsbtIn.BIP32_DERIVATION) + .map((keyDatas) => { + return keyDatas.map((kDataHex) => { + const buf = new ByteArrayBuilder(); + return buf.addHexaStringToData(kDataHex).build(); + }); + }); + pubkeys.map((pkeys) => { + if (pkeys.length === 0) { + // No legacy BIP32_DERIVATION, assume we're using taproot. + const pubkey = psbt + .getInputKeyDatas( + psbtSignature.inputIndex, + PsbtIn.TAP_BIP32_DERIVATION, + ) + .map((keyDatas) => { + return keyDatas.map((kDataHex) => { + const buf = new ByteArrayBuilder(); + return buf.addHexaStringToData(kDataHex).build(); + }); + }); + pubkey.map((pKey) => { + if (pKey.length === 0) { + throw new Error( + `Missing pubkey derivation for input ${psbtSignature.inputIndex}`, + ); + } + // @todo handle PSBT_IN_TAP_SCRIPT_SIG as described here https://github.com/bitcoin/bips/blob/master/bip-0371.mediawiki#specification + if (psbtSignature.tapleafHash !== undefined) { + throw new Error( + "Unhandled psbt key type PSBT_IN_TAP_SCRIPT_SIG", + ); + } + psbt.setInputValue( + psbtSignature.inputIndex, + PsbtIn.TAP_KEY_SIG, + new Value(psbtSignature.signature), + ); + }); + } else { + psbt.setInputValue( + psbtSignature.inputIndex, + PsbtIn.PARTIAL_SIG, + new Value(psbtSignature.signature), + psbtSignature.pubkey, + ); + } + }); + } + return psbt; + }); + } + + /** + * Updates a provided Partially Signed Bitcoin Transaction (PSBT) by verifying + * the presence of signatures for each input and processing them accordingly. + * + * @param {InternalPsbt} fromPsbt - The original PSBT object to be updated. + * @return {Either} Either an error if an issue arises during processing + * or the updated PSBT object after processing all inputs. + */ + private getUpdatedPsbt(fromPsbt: InternalPsbt): Either { + return Either.encase(() => { + let psbt = fromPsbt; + // First check that each input has a signature + const inputCount = psbt + .getGlobalValue(PsbtGlobal.INPUT_COUNT) + .mapOrDefault( + (value) => this._valueParser.getVarint(value.data).orDefault(0), + 0, + ); + for (let i = 0; i < inputCount; i++) { + const legacyPubkeys = psbt + .getInputKeyDatas(i, PsbtIn.PARTIAL_SIG) + .mapOrDefault((keys) => { + return keys.map((keyDataHex) => { + const buf = new ByteArrayBuilder(); + return buf.addHexaStringToData(keyDataHex).build(); + }); + }, []); + const taprootSig = psbt.getInputValue(i, PsbtIn.TAP_KEY_SIG); + if (legacyPubkeys.length === 0 && taprootSig.isNothing()) { + throw Error(`No signature for input ${i} present`); + } + if (legacyPubkeys.length > 0) { + psbt = this.getLegacyUpdatedPsbtInput(psbt, i, legacyPubkeys); + } else { + psbt = this.getTaprootUpdatedPsbtInput(psbt, i); + } + this.clearUpdatedPsbtInput(psbt, i); + } + return psbt; + }); + } + + /** + * Clears specific updated entries from the given PSBT input at the specified index, + * ensuring only the necessary details are retained. + * + * @param {InternalPsbt} fromPsbt - The PSBT (Partially Signed Bitcoin Transaction) object to modify. + * @param {number} inputIndex - The index of the input to be processed. + * @return {InternalPsbt} The updated PSBT object with the specified entries cleared. + */ + private clearUpdatedPsbtInput( + fromPsbt: InternalPsbt, + inputIndex: number, + ): InternalPsbt { + const psbt = fromPsbt; + const keyTypes = [ + PsbtIn.BIP32_DERIVATION, + PsbtIn.PARTIAL_SIG, + PsbtIn.TAP_BIP32_DERIVATION, + PsbtIn.TAP_KEY_SIG, + ]; + const witnessUtxoAvailable = psbt + .getInputValue(inputIndex, PsbtIn.WITNESS_UTXO) + .isJust(); + const nonWitnessUtxoAvailable = psbt + .getInputValue(inputIndex, PsbtIn.NON_WITNESS_UTXO) + .isJust(); + if (witnessUtxoAvailable && nonWitnessUtxoAvailable) { + // Remove NON_WITNESS_UTXO for segwit v0 as it's only needed while signing. + // Segwit v1 doesn't have NON_WITNESS_UTXO set. + // See https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki#cite_note-7 + keyTypes.push(PsbtIn.NON_WITNESS_UTXO); + } + psbt.deleteInputEntries(inputIndex, keyTypes); + return psbt; + } + + /** + * Updates a PSBT (Partially Signed Bitcoin Transaction) input with legacy signature data. + * + * @param fromPsbt - The original PSBT object to be updated. + * @param inputIndex - The index of the specific input to update within the PSBT. + * @param legacyPubkeys - Array containing one legacy public key related to the input. + * @return The updated PSBT object with the finalized legacy input data. + * @throws Will throw an error if multiple or no legacy public keys are provided. + * @throws Will throw an error if both taproot and non-taproot signatures are present. + * @throws Will throw an error if a partial signature for the input is not found. + * @throws Will throw an error if a non-empty redeem script is expected but not present. + */ + private getLegacyUpdatedPsbtInput( + fromPsbt: InternalPsbt, + inputIndex: number, + legacyPubkeys: Uint8Array[], + ): InternalPsbt { + const psbt = fromPsbt; + const taprootSig = psbt.getInputValue(inputIndex, PsbtIn.TAP_KEY_SIG); + if (legacyPubkeys.length > 1) { + throw Error( + `Expected exactly one signature, got ${legacyPubkeys.length}`, + ); + } + if (taprootSig.isJust()) { + throw Error("Both taproot and non-taproot signatures present."); + } + const isSegwitV0 = psbt + .getInputValue(inputIndex, PsbtIn.WITNESS_UTXO) + .isJust(); + + const redeemScript = psbt.getInputValue(inputIndex, PsbtIn.REDEEM_SCRIPT); + const isWrappedSegwit = redeemScript.isJust(); + const signature = psbt.getKeyDataInputValue( + inputIndex, + PsbtIn.PARTIAL_SIG, + legacyPubkeys[0]!, + ); + if (signature.isNothing()) { + throw new Error("Expected partial signature for input " + inputIndex); + } + const sig = signature.mapOrDefault((v) => v.data, Uint8Array.from([])); + if (isSegwitV0) { + const buffer = new ByteArrayBuilder(); + buffer.addBufferToData( + encodeVarint(2).mapOrDefault((v) => v, Uint8Array.from([2])), + ); + buffer.addBufferToData( + encodeVarint(sig.length).orDefault(Uint8Array.from([0])), + ); + buffer.addBufferToData(sig); + buffer.addBufferToData( + encodeVarint(legacyPubkeys[0]!.length).orDefault(Uint8Array.from([0])), + ); + buffer.addBufferToData(legacyPubkeys[0]!); + psbt.setInputValue( + inputIndex, + PsbtIn.FINAL_SCRIPTWITNESS, + new Value(buffer.build()), + ); + if (isWrappedSegwit) { + const rScript = redeemScript.mapOrDefault( + (v) => v.data, + Uint8Array.from([]), + ); + if (rScript.length == 0) { + throw new Error( + "Expected non-empty redeemscript. Can't finalize intput " + + inputIndex, + ); + } + const scriptSigBuf = new ByteArrayBuilder(); + + // Push redeemScript length + scriptSigBuf.add8BitUIntToData(rScript.length); + scriptSigBuf.addBufferToData(rScript); + psbt.setInputValue( + inputIndex, + PsbtIn.FINAL_SCRIPTSIG, + new Value(scriptSigBuf.build()), + ); + } + } else { + // Legacy input + const scriptSig = new ByteArrayBuilder(); + scriptSig.addBufferToData(encodeScriptOperations(sig)); + scriptSig.addBufferToData(encodeScriptOperations(legacyPubkeys[0]!)); + psbt.setInputValue( + inputIndex, + PsbtIn.FINAL_SCRIPTSIG, + new Value(scriptSig.build()), + ); + } + return psbt; + } + + /** + * Updates the given PSBT input with the taproot signature and constructs + * the final script witness for the specified input index. + * + * @param {InternalPsbt} fromPsbt - The PSBT object containing the input to be updated. + * @param {number} inputIndex - The index of the input in the PSBT that needs to be updated. + * @return {InternalPsbt} The updated PSBT object with the finalized script witness for the taproot input. + * @throws {Error} If there is no signature for the taproot input or if the signature length is invalid. + */ + private getTaprootUpdatedPsbtInput( + fromPsbt: InternalPsbt, + inputIndex: number, + ): InternalPsbt { + const psbt = fromPsbt; + const maybeSignature = psbt.getInputValue(inputIndex, PsbtIn.TAP_KEY_SIG); + if (maybeSignature.isNothing()) { + throw Error("No signature for taproot input " + inputIndex); + } + const signature = maybeSignature.mapOrDefault( + (v) => v.data, + Uint8Array.from([]), + ); + + if ( + ![SCHORR_SIG_LENGTH, SCHORR_SIG_LENGTH_WITH_EXTRA_BYTE].includes( + signature.length, + ) + ) { + throw Error("Unexpected length of schnorr signature."); + } + const witnessBufferBuilder = new ByteArrayBuilder(); + witnessBufferBuilder.add8BitUIntToData(0x01); + witnessBufferBuilder.encodeInLVFromBuffer(signature); + psbt.setInputValue( + inputIndex, + PsbtIn.FINAL_SCRIPTWITNESS, + new Value(witnessBufferBuilder.build()), + ); + return psbt; + } +} diff --git a/packages/signer/signer-btc/src/internal/psbt/model/Psbt.test.ts b/packages/signer/signer-btc/src/internal/psbt/model/Psbt.test.ts index 6077449ad..bc07981ee 100644 --- a/packages/signer/signer-btc/src/internal/psbt/model/Psbt.test.ts +++ b/packages/signer/signer-btc/src/internal/psbt/model/Psbt.test.ts @@ -1,3 +1,5 @@ +import { Just } from "purify-ts"; + import { Key } from "./Key"; import { Psbt, PsbtGlobal, PsbtIn, PsbtOut } from "./Psbt"; import { Value } from "./Value"; @@ -20,6 +22,13 @@ describe("Psbt", () => { new Key(PsbtIn.WITNESS_UTXO).toHexaString(), new Value(Uint8Array.of(0x03)), ], + [ + new Key( + PsbtIn.PARTIAL_SIG, + Uint8Array.from([0x01, 0x02]), + ).toHexaString(), + new Value(Uint8Array.of(0x07)), + ], [ new Key(PsbtIn.NON_WITNESS_UTXO).toHexaString(), new Value(Uint8Array.of(0x04)), @@ -67,6 +76,40 @@ describe("Psbt", () => { ).toStrictEqual(Uint8Array.of(0x06)); }); + it("should return the correct input key data", () => { + expect( + TEST_PSBT.getKeyDataInputValue( + 0, + PsbtIn.PARTIAL_SIG, + Uint8Array.from([0x01, 0x02]), + ), + ).toStrictEqual(Just(new Value(Uint8Array.from([0x07])))); + }); + it("should return all key datas corresponding to a given input", () => { + // given + const psbt = new Psbt(new Map(), [ + new Map([ + [ + new Key( + PsbtIn.TAP_BIP32_DERIVATION, + Uint8Array.from([0x03]), + ).toHexaString(), + new Value(Uint8Array.of(0x23)), + ], + [ + new Key( + PsbtIn.TAP_BIP32_DERIVATION, + Uint8Array.from([0x04]), + ).toHexaString(), + new Value(Uint8Array.of(0x32)), + ], + ]), + ]); + // when + const keyDatas = psbt.getInputKeyDatas(0, PsbtIn.TAP_BIP32_DERIVATION); + // then + expect(keyDatas).toStrictEqual(Just(["03", "04"])); + }); it("missing global key", () => { expect( TEST_PSBT.getGlobalValue(PsbtGlobal.TX_MODIFIABLE).isJust(), @@ -96,4 +139,106 @@ describe("Psbt", () => { TEST_PSBT.getOutputValue(0, PsbtOut.TAP_TREE).isJust(), ).toStrictEqual(false); }); + + it("should set input value", () => { + // given + const psbt = new Psbt(new Map(), [new Map()]); + const value = new Value(Uint8Array.of(0x03)); + // when + psbt.setInputValue(0, PsbtIn.PARTIAL_SIG, value); + // then + expect(psbt).toStrictEqual( + new Psbt( + new Map(), + [new Map([[new Key(PsbtIn.PARTIAL_SIG).toHexaString(), value]])], + [], + ), + ); + }); + it("should set output value", () => { + // given + const psbt = new Psbt(new Map(), [], [new Map()]); + const value = new Value(Uint8Array.of(0x42)); + // when + psbt.setOutputValue(0, PsbtOut.AMOUNT, value); + // then + expect(psbt).toStrictEqual( + new Psbt( + new Map(), + [], + [new Map([[new Key(PsbtOut.AMOUNT).toHexaString(), value]])], + ), + ); + }); + it("should set global value", () => { + // given + const psbt = new Psbt(); + const value = new Value(Uint8Array.of(0x09)); + // when + psbt.setGlobalValue(PsbtGlobal.VERSION, value); + // then + expect(psbt).toStrictEqual( + new Psbt(new Map([[new Key(PsbtGlobal.VERSION).toHexaString(), value]])), + ); + }); + it("should set input key data", () => { + // given + const psbt = new Psbt(new Map(), [new Map()]); + const value = new Value(Uint8Array.of(0x03)); + // when + psbt.setInputValue(0, PsbtIn.PARTIAL_SIG, value, Uint8Array.from([0x42])); + // then + expect(psbt).toStrictEqual( + new Psbt( + new Map(), + [ + new Map([ + [ + new Key( + PsbtIn.PARTIAL_SIG, + Uint8Array.from([0x42]), + ).toHexaString(), + value, + ], + ]), + ], + [], + ), + ); + }); + it("should remove input entries", () => { + // given + const psbt = new Psbt(new Map(), [ + new Map([ + [ + new Key(PsbtIn.WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.of(0x03)), + ], + [ + new Key( + PsbtIn.PARTIAL_SIG, + Uint8Array.from([0x01, 0x02]), + ).toHexaString(), + new Value(Uint8Array.of(0x07)), + ], + [ + new Key(PsbtIn.NON_WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.of(0x04)), + ], + ]), + ]); + // when + psbt.deleteInputEntries(0, [PsbtIn.PARTIAL_SIG, PsbtIn.NON_WITNESS_UTXO]); + // then + expect(psbt).toStrictEqual( + new Psbt(new Map(), [ + new Map([ + [ + new Key(PsbtIn.WITNESS_UTXO).toHexaString(), + new Value(Uint8Array.of(0x03)), + ], + ]), + ]), + ); + }); }); diff --git a/packages/signer/signer-btc/src/internal/psbt/model/Psbt.ts b/packages/signer/signer-btc/src/internal/psbt/model/Psbt.ts index 425c7b1b4..f5dcfab35 100644 --- a/packages/signer/signer-btc/src/internal/psbt/model/Psbt.ts +++ b/packages/signer/signer-btc/src/internal/psbt/model/Psbt.ts @@ -95,6 +95,15 @@ export class Psbt { ); } + getInputKeyDatas(inputIndex: number, keyType: PsbtIn): Maybe { + const key = new Key(keyType).toHexaString(); + return Maybe.fromNullable(this.inputMaps[inputIndex]).map((input) => { + return Array.from(input.keys()) + .filter((k) => k.startsWith(key)) + .map((k) => k.slice(2)); + }); + } + getOutputValue(outputIndex: number, key: PsbtOut): Maybe { return Maybe.fromNullable(this.outputMaps[outputIndex]).chain((output) => Maybe.fromNullable(output.get(new Key(key).toHexaString())), @@ -105,11 +114,43 @@ export class Psbt { this.globalMap.set(new Key(key).toHexaString(), value); } - setInputValue(inputIndex: number, key: PsbtIn, value: Value) { - this.inputMaps[inputIndex]?.set(new Key(key).toHexaString(), value); + setInputValue( + inputIndex: number, + key: PsbtIn, + value: Value, + keyData?: Uint8Array, + ) { + this.inputMaps[inputIndex]?.set( + new Key(key, keyData).toHexaString(), + value, + ); + } + + getKeyDataInputValue( + inputIndex: number, + keyIn: PsbtIn, + keyData: Uint8Array, + ): Maybe { + return Maybe.fromNullable(this.inputMaps[inputIndex]).chain((input) => + Maybe.fromNullable(input.get(new Key(keyIn, keyData).toHexaString())), + ); } setOutputValue(outputIndex: number, key: PsbtOut, value: Value) { this.outputMaps[outputIndex]?.set(new Key(key).toHexaString(), value); } + + deleteInputEntries(inputIndex: number, keyTypes: PsbtIn[]) { + const maybeInputEntries = Maybe.fromNullable(this.inputMaps[inputIndex]); + maybeInputEntries.map((inputEntries) => { + keyTypes.forEach((keyType) => { + const key = new Key(keyType); + inputEntries.forEach((_value, k, map) => { + if (k.startsWith(key.toHexaString())) { + map.delete(k); + } + }); + }); + }); + } } diff --git a/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueFactory.ts b/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueFactory.ts index 2d1887516..7827f3221 100644 --- a/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueFactory.ts +++ b/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueFactory.ts @@ -2,9 +2,10 @@ import { ByteArrayBuilder } from "@ledgerhq/device-management-kit"; import { Maybe } from "purify-ts"; import { Value } from "@internal/psbt/model/Value"; +import { type ValueFactory } from "@internal/psbt/service/value/ValueFactory"; import { encodeVarint } from "@internal/utils/Varint"; -export class DefaultValueFactory { +export class DefaultValueFactory implements ValueFactory { fromInt32LE(value: number): Maybe { return Maybe.fromNullable( new ByteArrayBuilder().add32BitIntToData(value, false).tryBuild(), diff --git a/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueParser.test.ts b/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueParser.test.ts index 825ef0ada..5e716889e 100644 --- a/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueParser.test.ts +++ b/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueParser.test.ts @@ -44,6 +44,68 @@ describe("DefaultValueParser", () => { }); }); + describe("getUInt32LE", () => { + it("Get an unsigned 32-bit positive integer", () => { + // GIVEN + const data = Uint8Array.from([214, 255, 255, 255]); + + // WHEN + const result = service.getUInt32LE(data); + + // THEN + expect(result.isJust()).toStrictEqual(true); + expect(result.unsafeCoerce()).toStrictEqual(4294967254); + }); + + it("Invalid data", () => { + // GIVEN + const data = Uint8Array.from([0, 0, 0]); + + // WHEN + const result = service.getUInt32LE(data); + + // THEN + expect(result.isJust()).toStrictEqual(false); + }); + }); + + describe("getInt64LE", () => { + it("Get a signed 64-bit positive integer", () => { + // GIVEN + const data = Uint8Array.from([42, 0, 0, 0, 0, 0, 0, 0]); + + // WHEN + const result = service.getInt64LE(data); + + // THEN + expect(result.isJust()).toStrictEqual(true); + expect(result.unsafeCoerce()).toStrictEqual(42n); + }); + + it("Get a signed 64-bit negative integer", () => { + // GIVEN + const data = Uint8Array.from([214, 255, 255, 255, 255, 255, 255, 255]); + + // WHEN + const result = service.getInt64LE(data); + + // THEN + expect(result.isJust()).toStrictEqual(true); + expect(result.unsafeCoerce()).toStrictEqual(-42n); + }); + + it("Invalid data", () => { + // GIVEN + const data = Uint8Array.from([0, 0, 0, 0, 0]); + + // WHEN + const result = service.getInt64LE(data); + + // THEN + expect(result.isJust()).toStrictEqual(false); + }); + }); + describe("getVarint", () => { it("Get a varint", () => { // GIVEN diff --git a/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueParser.ts b/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueParser.ts index ffb3aa451..07c2a1da7 100644 --- a/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueParser.ts +++ b/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueParser.ts @@ -1,13 +1,24 @@ import { ByteArrayParser } from "@ledgerhq/device-management-kit"; import { Maybe } from "purify-ts"; +import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; import { extractVarint } from "@internal/utils/Varint"; -export class DefaultValueParser { +export class DefaultValueParser implements ValueParser { getInt32LE(data: Uint8Array): Maybe { return Maybe.fromNullable(new ByteArrayParser(data).extract32BitInt(false)); } + getUInt32LE(data: Uint8Array): Maybe { + return Maybe.fromNullable( + new ByteArrayParser(data).extract32BitUInt(false), + ); + } + + getInt64LE(data: Uint8Array): Maybe { + return Maybe.fromNullable(new ByteArrayParser(data).extract64BitInt(false)); + } + getVarint(data: Uint8Array): Maybe { return extractVarint(new ByteArrayParser(data)).map( (varint) => varint.value, diff --git a/packages/signer/signer-btc/src/internal/psbt/service/value/ValueParser.ts b/packages/signer/signer-btc/src/internal/psbt/service/value/ValueParser.ts index 793e9cabf..3fb9caf99 100644 --- a/packages/signer/signer-btc/src/internal/psbt/service/value/ValueParser.ts +++ b/packages/signer/signer-btc/src/internal/psbt/service/value/ValueParser.ts @@ -2,5 +2,7 @@ import { type Maybe } from "purify-ts"; export interface ValueParser { getInt32LE(data: Uint8Array): Maybe; + getUInt32LE(data: Uint8Array): Maybe; + getInt64LE(data: Uint8Array): Maybe; getVarint(data: Uint8Array): Maybe; } 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 b66e24168..f821e5174 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 @@ -4,6 +4,7 @@ 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"; +import { SignTransactionUseCase } from "@internal/use-cases/sign-transaction/SignTransactionUseCase"; export const useCasesModuleFactory = () => new ContainerModule( @@ -21,5 +22,6 @@ export const useCasesModuleFactory = () => ); bind(useCasesTypes.SignMessageUseCase).to(SignMessageUseCase); bind(useCasesTypes.SignPsbtUseCase).to(SignPsbtUseCase); + bind(useCasesTypes.SignTransactionUseCase).to(SignTransactionUseCase); }, ); 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 e2ebd8142..a836244e3 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 @@ -2,4 +2,5 @@ export const useCasesTypes = { GetExtendedPublicKeyUseCase: Symbol.for("GetExtendedPublicKeyUseCase"), SignMessageUseCase: Symbol.for("SignMessageUseCase"), SignPsbtUseCase: Symbol.for("SignPsbtUseCase"), + SignTransactionUseCase: Symbol.for("SignTransactionUseCase"), }; diff --git a/packages/signer/signer-btc/src/internal/use-cases/sign-transaction/SignTransactionUseCase.test.ts b/packages/signer/signer-btc/src/internal/use-cases/sign-transaction/SignTransactionUseCase.test.ts new file mode 100644 index 000000000..363d59570 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/use-cases/sign-transaction/SignTransactionUseCase.test.ts @@ -0,0 +1,29 @@ +import { DefaultDescriptorTemplate, DefaultWallet } from "@api/model/Wallet"; +import { type BtcAppBinder } from "@internal/app-binder/BtcAppBinder"; +import { SignTransactionUseCase } from "@internal/use-cases/sign-transaction/SignTransactionUseCase"; + +describe("SignTransactionUseCase", () => { + it("should call signTransaction on appBinder with the correct arguments", () => { + // Given + const wallet = new DefaultWallet( + "84'/0'/0'", + DefaultDescriptorTemplate.NATIVE_SEGWIT, + ); + const psbt = "some-psbt"; + const appBinder = { + signTransaction: jest.fn(), + }; + const signTransactionUseCase = new SignTransactionUseCase( + appBinder as unknown as BtcAppBinder, + ); + + // When + signTransactionUseCase.execute(wallet, psbt); + + // Then + expect(appBinder.signTransaction).toHaveBeenCalledWith({ + wallet, + psbt, + }); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/use-cases/sign-transaction/SignTransactionUseCase.ts b/packages/signer/signer-btc/src/internal/use-cases/sign-transaction/SignTransactionUseCase.ts new file mode 100644 index 000000000..d30f32118 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/use-cases/sign-transaction/SignTransactionUseCase.ts @@ -0,0 +1,26 @@ +import { inject, injectable } from "inversify"; + +import { SignTransactionDAReturnType } from "@api/app-binder/SignTransactionDeviceActionTypes"; +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 SignTransactionUseCase { + private _appBinder: BtcAppBinder; + + constructor( + @inject(appBinderTypes.AppBinder) + appBinding: BtcAppBinder, + ) { + this._appBinder = appBinding; + } + + execute(wallet: Wallet, psbt: Psbt): SignTransactionDAReturnType { + return this._appBinder.signTransaction({ + wallet, + psbt, + }); + } +} diff --git a/packages/signer/signer-btc/src/internal/utils/ScriptOperation.test.ts b/packages/signer/signer-btc/src/internal/utils/ScriptOperation.test.ts new file mode 100644 index 000000000..5b5ef5f5d --- /dev/null +++ b/packages/signer/signer-btc/src/internal/utils/ScriptOperation.test.ts @@ -0,0 +1,28 @@ +import { encodeScriptOperations } from "@internal/utils/ScriptOperation"; + +describe("ScriptOperation", () => { + it("should return buffer containing data length", () => { + // given + const data = new Uint8Array(new Array(0x4d).fill(42)); + // when + const ret = encodeScriptOperations(data); + // then + expect(ret).toStrictEqual(new Uint8Array([0x4d, ...data])); + }); + it("should return buffer containing data length", () => { + // given + const data = new Uint8Array(new Array(0xfe).fill(42)); + // when + const ret = encodeScriptOperations(data); + // then + expect(ret).toStrictEqual(new Uint8Array([0x4c, 0xfe, ...data])); + }); + it("should return buffer containing data length", () => { + // given + const data = new Uint8Array(new Array(0x1000).fill(42)); + // when + const ret = encodeScriptOperations(data); + // then + expect(ret).toStrictEqual(new Uint8Array([0x4d, 0x00, 0x10, ...data])); + }); +}); diff --git a/packages/signer/signer-btc/src/internal/utils/ScriptOperation.ts b/packages/signer/signer-btc/src/internal/utils/ScriptOperation.ts new file mode 100644 index 000000000..044cb3e2d --- /dev/null +++ b/packages/signer/signer-btc/src/internal/utils/ScriptOperation.ts @@ -0,0 +1,34 @@ +import { ByteArrayBuilder } from "@ledgerhq/device-management-kit"; + +const OP_PUSHDATA1 = 0x4c; +const OP_PUSHDATA2 = 0x4d; + +const OP_PUSHLENGTH_MAX = 0x4e; +const OP_PUSHDATA1_MAX = 0xff; +const OP_PUSHDATA2_MAX = 0xffff; + +/** + * Writes a script push operation to buf, which looks different + * depending on the size of the data. See + * https://en.bitcoin.it/wiki/Script#Constants + * + * @param {Uint8Array} data - The input data to be encoded. + * @return {Uint8Array} - The encoded script operation data as a byte array. + */ +export function encodeScriptOperations(data: Uint8Array) { + const buf = new ByteArrayBuilder(); + if (data.length <= OP_PUSHLENGTH_MAX) { + buf.add8BitUIntToData(data.length); + } else if (data.length <= OP_PUSHDATA1_MAX) { + buf.add8BitUIntToData(OP_PUSHDATA1); + buf.add8BitUIntToData(data.length); + } else if (data.length <= OP_PUSHDATA2_MAX) { + buf.add8BitUIntToData(OP_PUSHDATA2); + const b = new ByteArrayBuilder() + .add16BitUIntToData(data.length, false) + .build(); + buf.addBufferToData(b); + } + buf.addBufferToData(data); + return buf.build(); +}