From 06753e01cc9b79291f459adbef3068e9cb7a7c3b Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Thu, 16 Jan 2025 21:22:12 +0100 Subject: [PATCH] :white_check_mark: (signer-btc): Add tests for update psbt & sign transaction --- .../SignTransactionDeviceActionTypes.ts | 7 +- .../src/internal/DefaultSignerBtc.test.ts | 29 ++ .../SignTransactionDeviceAction.test.ts | 324 ++++++++++++++++++ .../SignTransactionDeviceAction.ts | 7 +- .../__test-utils__/setupSignPsbtDAMock.ts | 37 ++ .../task/ExtractTransactionTask.test.ts | 84 +++++ .../app-binder/task/ExtractTransactionTask.ts | 12 +- .../app-binder/task/UpdatePsbtTask.test.ts | 257 ++++++++++++++ .../app-binder/task/UpdatePsbtTask.ts | 102 ++++-- .../SignTransactionUseCase.test.ts | 29 ++ .../internal/utils/ScriptOperation.test.ts | 28 ++ .../src/internal/utils/ScriptOperation.ts | 34 ++ 12 files changed, 919 insertions(+), 31 deletions(-) create mode 100644 packages/signer/signer-btc/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/device-action/__test-utils__/setupSignPsbtDAMock.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.test.ts create mode 100644 packages/signer/signer-btc/src/internal/use-cases/sign-transaction/SignTransactionUseCase.test.ts create mode 100644 packages/signer/signer-btc/src/internal/utils/ScriptOperation.test.ts create mode 100644 packages/signer/signer-btc/src/internal/utils/ScriptOperation.ts diff --git a/packages/signer/signer-btc/src/api/app-binder/SignTransactionDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/SignTransactionDeviceActionTypes.ts index 21db7b5f8..750c0c3b5 100644 --- a/packages/signer/signer-btc/src/api/app-binder/SignTransactionDeviceActionTypes.ts +++ b/packages/signer/signer-btc/src/api/app-binder/SignTransactionDeviceActionTypes.ts @@ -2,15 +2,16 @@ 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 PsbtSignature } from "@internal/app-binder/task/SignPsbtTask"; 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"; @@ -18,7 +19,7 @@ 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 = string; +export type SignTransactionDAOutput = HexaString; export type SignTransactionDAInput = { psbt: ApiPsbt; @@ -52,7 +53,7 @@ export type SignTransactionDAInternalState = { readonly error: SignTransactionDAError | null; readonly signatures: PsbtSignature[] | null; readonly signedPsbt: InternalPsbt | null; - readonly transaction: string | null; + readonly transaction: HexaString | null; }; export type SignTransactionDAReturnType = ExecuteDeviceActionReturnType< 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/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 index 4fd51afc2..708bc4674 100644 --- 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 @@ -1,6 +1,7 @@ import { type CommandResult, type DeviceActionStateMachine, + type HexaString, type InternalApi, isSuccessCommandResult, type StateMachineTypes, @@ -19,10 +20,10 @@ import { 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 { type PsbtSignature } from "@internal/app-binder/task/SignPsbtTask"; 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"; @@ -39,7 +40,7 @@ export type MachineDependencies = { }) => Promise>; readonly extractTransaction: (arg0: { input: { psbt: InternalPsbt; valueParser: ValueParser }; - }) => Promise>; + }) => Promise>; }; export type ExtractMachineDependencies = ( @@ -286,7 +287,7 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< const extractTransaction = async (arg0: { input: { psbt: InternalPsbt; valueParser: ValueParser }; - }): Promise> => { + }): Promise> => { const { input: { psbt, valueParser }, } = arg0; 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..4b0097085 --- /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: "0x0000000000010108000000000293980000000000203009080706", + }), + ); + }); +}); 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 index 384583722..df1e7b9c0 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.ts @@ -3,6 +3,7 @@ import { ByteArrayBuilder, type CommandResult, CommandResultFactory, + type HexaString, } from "@ledgerhq/device-management-kit"; import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; @@ -24,7 +25,14 @@ export class ExtractTransactionTask { private readonly _args: ExtractTransactionTaskArgs, private readonly _valueParser: ValueParser, ) {} - run(): CommandResult { + + /** + * 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 @@ -93,7 +101,7 @@ export class ExtractTransactionTask { .orDefault(0); transaction.add32BitUIntToData(locktime, false); return CommandResultFactory({ - data: bufferToHexaString(transaction.build()).slice(2), + 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..73b688145 --- /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, 0x21, 0x01, 0x42])), + ], + ]), + ], + ), + }), + ); + }); + 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, 0x21, 0x01, 0x42])), + ], + [ + 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 index 8e22053a2..4873c35a0 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.ts @@ -7,8 +7,12 @@ import { 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 PsbtSignature } from "@internal/app-binder/task/SignPsbtTask"; import { type Psbt as InternalPsbt, PsbtGlobal, @@ -17,6 +21,7 @@ import { 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 = { @@ -31,8 +36,31 @@ export class UpdatePsbtTask { 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 } = this._args; + 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)); @@ -47,9 +75,16 @@ export class UpdatePsbtTask { }); } + /** + * 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: PsbtSignature[], + psbtSignatures: PartialSignature[], ): Either { return Either.encase(() => { for (const psbtSignature of psbtSignatures) { @@ -64,7 +99,7 @@ export class UpdatePsbtTask { }); }); pubkeys.map((pkeys) => { - if (pkeys.length != 1) { + if (pkeys.length === 0) { // No legacy BIP32_DERIVATION, assume we're using taproot. const pubkey = psbt .getInputKeyDatas( @@ -94,7 +129,7 @@ export class UpdatePsbtTask { psbtSignature.inputIndex, PsbtIn.PARTIAL_SIG, psbtSignature.signature, - new Value(psbtSignature.pubKeyAugmented), + new Value(psbtSignature.pubkey), ); } }); @@ -103,6 +138,14 @@ export class UpdatePsbtTask { }); } + /** + * 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; @@ -137,6 +180,14 @@ export class UpdatePsbtTask { }); } + /** + * 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, @@ -164,6 +215,18 @@ export class UpdatePsbtTask { 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, @@ -237,8 +300,8 @@ export class UpdatePsbtTask { } else { // Legacy input const scriptSig = new ByteArrayBuilder(); - writePush(scriptSig, sig); - writePush(scriptSig, legacyPubkeys[0]!); + scriptSig.addBufferToData(encodeScriptOperations(sig)); + scriptSig.addBufferToData(encodeScriptOperations(legacyPubkeys[0]!)); psbt.setInputValue( inputIndex, PsbtIn.FINAL_SCRIPTSIG, @@ -248,6 +311,15 @@ export class UpdatePsbtTask { 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, @@ -279,19 +351,3 @@ export class UpdatePsbtTask { return psbt; } } - -function writePush(buf: ByteArrayBuilder, data: Uint8Array) { - if (data.length <= 75) { - buf.add8BitUIntToData(data.length); - } else if (data.length <= 256) { - buf.add8BitUIntToData(76); - buf.add8BitUIntToData(data.length); - } else if (data.length <= 256 * 256) { - buf.add8BitUIntToData(77); - const b = new ByteArrayBuilder() - .add16BitUIntToData(data.length, false) - .build(); - buf.addBufferToData(b); - } - buf.addBufferToData(data); -} 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/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(); +}