From 447b3e3d9f44d27b0503934355b5d0a8f655b912 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Mon, 13 Jan 2025 12:16:55 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20(signer-btc):=20Create=20Update?= =?UTF-8?q?Psbt=20and=20ExtractTransaction=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app-binder/task/ExtractTransactionTask.ts | 99 ++++++ .../app-binder/task/UpdatePsbtTask.ts | 297 ++++++++++++++++++ .../src/internal/psbt/model/Psbt.test.ts | 150 +++++++++ .../src/internal/psbt/model/Psbt.ts | 45 +++ .../psbt/service/value/DefaultValueFactory.ts | 3 +- .../psbt/service/value/DefaultValueParser.ts | 9 +- .../psbt/service/value/ValueParser.ts | 1 + 7 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.ts 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..384583722 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/ExtractTransactionTask.ts @@ -0,0 +1,99 @@ +import { + bufferToHexaString, + ByteArrayBuilder, + type CommandResult, + CommandResultFactory, +} 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, + ) {} + 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.encodeInLVFromBuffer(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 ouputCount = psbt + .getGlobalValue(PsbtGlobal.OUTPUT_COUNT) + .chain((value) => this._valueParser.getUInt32LE(value.data)) + .orDefault(0); + transaction.addBufferToData(encodeVarint(ouputCount).extract()!); + for (let o = 0; o < ouputCount; o++) { + const amount = psbt + .getOutputValue(o, PsbtOut.AMOUNT) + .chain((value) => this._valueParser.getVarint(value.data)) + .orDefault(0); + const script = psbt + .getOutputValue(o, PsbtOut.SCRIPT) + .mapOrDefault((v) => v.data, Buffer.from([])); + transaction.addBufferToData(encodeVarint(amount).extract()!); + transaction.encodeInLVFromBuffer(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()).slice(2), + }); + } +} 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..8e22053a2 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/task/UpdatePsbtTask.ts @@ -0,0 +1,297 @@ +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 { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; +import { type PsbtSignature } from "@internal/app-binder/task/SignPsbtTask"; +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 { encodeVarint } from "@internal/utils/Varint"; + +type UpdatePsbtTaskArgs = { + psbt: ApiPsbt; + signatures: PsbtSignature[]; +}; + +export class UpdatePsbtTask { + constructor( + private readonly _args: UpdatePsbtTaskArgs, + private readonly _valueParser: ValueParser, + private readonly _psbtMapper: PsbtMapper, + ) {} + + public async run(): Promise> { + const { psbt: apiPsbt, signatures } = this._args; + 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 }), + }); + } + + private getSignedPsbt( + psbt: InternalPsbt, + psbtSignatures: PsbtSignature[], + ): 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 != 1) { + // 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}`, + ); + } + psbt.setInputValue( + psbtSignature.inputIndex, + PsbtIn.TAP_KEY_SIG, + new Value(psbtSignature.signature), + ); + }); + } else { + psbt.setKeyDataInputValue( + psbtSignature.inputIndex, + PsbtIn.PARTIAL_SIG, + psbtSignature.signature, + new Value(psbtSignature.pubKeyAugmented), + ); + } + }); + } + return psbt; + }); + } + + 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; + }); + } + + 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; + } + + 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(); + writePush(scriptSig, sig); + writePush(scriptSig, legacyPubkeys[0]!); + psbt.setInputValue( + inputIndex, + PsbtIn.FINAL_SCRIPTSIG, + new Value(scriptSig.build()), + ); + } + return psbt; + } + + private getTaprootUpdatedPsbtInput( + fromPsbt: InternalPsbt, + inputIndex: number, + ): InternalPsbt { + const psbt = fromPsbt; + // Taproot input + 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 (signature.length != 64 && signature.length != 65) { + throw Error("Unexpected length of schnorr signature."); + } + const witnessBufferBuilder = new ByteArrayBuilder(); + witnessBufferBuilder.addBufferToData( + encodeVarint(1).mapOrDefault((v) => v, Uint8Array.from([1])), + ); + witnessBufferBuilder.encodeInLVFromBuffer(signature); + psbt.setInputValue( + inputIndex, + PsbtIn.FINAL_SCRIPTWITNESS, + new Value(witnessBufferBuilder.build()), + ); + 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/psbt/model/Psbt.test.ts b/packages/signer/signer-btc/src/internal/psbt/model/Psbt.test.ts index 6077449ad..a95062746 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,111 @@ 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.setKeyDataInputValue( + 0, + PsbtIn.PARTIAL_SIG, + Uint8Array.from([0x42]), + value, + ); + // 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..b0742061d 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())), @@ -109,7 +118,43 @@ export class Psbt { this.inputMaps[inputIndex]?.set(new Key(key).toHexaString(), value); } + setKeyDataInputValue( + inputIndex: number, + keyIn: PsbtIn, + keyData: Uint8Array, + value: Value, + ) { + this.inputMaps[inputIndex]?.set( + new Key(keyIn, 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.ts b/packages/signer/signer-btc/src/internal/psbt/service/value/DefaultValueParser.ts index ffb3aa451..a804c9d30 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,20 @@ 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), + ); + } + 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..6411b7cda 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,6 @@ import { type Maybe } from "purify-ts"; export interface ValueParser { getInt32LE(data: Uint8Array): Maybe; + getUInt32LE(data: Uint8Array): Maybe; getVarint(data: Uint8Array): Maybe; } From 65685bae1a20404090ff141028f4f34c5eb0265e Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Mon, 13 Jan 2025 12:17:44 +0100 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20(signer-btc):=20Create=20SignTr?= =?UTF-8?q?ansactionn=20device=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app-binder/SignPsbtDeviceActionTypes.ts | 2 +- .../SignTransactionDeviceActionTypes.ts | 62 ++++ .../SignTransactionDeviceAction.ts | 301 ++++++++++++++++++ 3 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 packages/signer/signer-btc/src/api/app-binder/SignTransactionDeviceActionTypes.ts create mode 100644 packages/signer/signer-btc/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.ts 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..21db7b5f8 --- /dev/null +++ b/packages/signer/signer-btc/src/api/app-binder/SignTransactionDeviceActionTypes.ts @@ -0,0 +1,62 @@ +import { + type CommandErrorResult, + type DeviceActionState, + type ExecuteDeviceActionReturnType, + 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 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"; +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 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: string | null; +}; + +export type SignTransactionDAReturnType = ExecuteDeviceActionReturnType< + SignTransactionDAOutput, + SignTransactionDAError, + SignTransactionDAIntermediateValue +>; 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..4fd51afc2 --- /dev/null +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignTransaction/SignTransactionDeviceAction.ts @@ -0,0 +1,301 @@ +import { + type CommandResult, + type DeviceActionStateMachine, + 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 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"; +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, + }; + } +} From 000d38addc6f79c1171a5a334b1b704b91e07f8d Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Mon, 13 Jan 2025 12:18:12 +0100 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20(signer-btc):=20Sign=20transact?= =?UTF-8?q?ion=20use=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../signer/signer-btc/src/api/SignerBtc.ts | 3 ++- packages/signer/signer-btc/src/api/index.ts | 1 + .../src/internal/DefaultSignerBtc.ts | 7 +++++ .../src/internal/app-binder/BtcAppBinder.ts | 22 ++++++++++++++++ .../internal/use-cases/di/useCasesModule.ts | 2 ++ .../internal/use-cases/di/useCasesTypes.ts | 1 + .../SignTransactionUseCase.ts | 26 +++++++++++++++++++ 7 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 packages/signer/signer-btc/src/internal/use-cases/sign-transaction/SignTransactionUseCase.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/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.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/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.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, + }); + } +} From f919cd27637bc1e0715011cfcee47d75897006b8 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Mon, 13 Jan 2025 12:18:31 +0100 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=92=84=20(smpl):=20Sign=20transaction?= =?UTF-8?q?=20in=20signer=20btc=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SignPsbtDAInputValusForm.tsx | 2 +- .../src/components/SignerBtcView/index.tsx | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) 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], ); From 06753e01cc9b79291f459adbef3068e9cb7a7c3b Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Thu, 16 Jan 2025 21:22:12 +0100 Subject: [PATCH 5/7] :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(); +} From b78c6e2a5c26b5bf67c461d67b62b4087e12290f Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 17 Jan 2025 13:11:25 +0100 Subject: [PATCH 6/7] :art: (signer-btc): Reviews --- .../task/ExtractTransactionTask.test.ts | 2 +- .../app-binder/task/ExtractTransactionTask.ts | 20 +++--- .../app-binder/task/UpdatePsbtTask.test.ts | 4 +- .../app-binder/task/UpdatePsbtTask.ts | 26 +++++--- .../src/internal/psbt/model/Psbt.test.ts | 7 +-- .../src/internal/psbt/model/Psbt.ts | 12 ++-- .../service/value/DefaultValueParser.test.ts | 62 +++++++++++++++++++ .../psbt/service/value/DefaultValueParser.ts | 4 ++ .../psbt/service/value/ValueParser.ts | 1 + 9 files changed, 104 insertions(+), 34 deletions(-) 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 index 4b0097085..6309287e1 100644 --- 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 @@ -77,7 +77,7 @@ describe("ExtractTransactionTask", () => { // then expect(tx).toStrictEqual( CommandResultFactory({ - data: "0x0000000000010108000000000293980000000000203009080706", + 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 index df1e7b9c0..d6c58d84a 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 @@ -65,7 +65,8 @@ export class ExtractTransactionTask { const scriptSig = psbt .getInputValue(i, PsbtIn.FINAL_SCRIPTSIG) .mapOrDefault((v) => v.data, Uint8Array.from([])); - transaction.encodeInLVFromBuffer(scriptSig); + transaction.addBufferToData(encodeVarint(scriptSig.length).extract()!); + transaction.addBufferToData(scriptSig); const sequence = psbt .getInputValue(i, PsbtIn.SEQUENCE) .chain((value) => this._valueParser.getUInt32LE(value.data)) @@ -78,21 +79,22 @@ export class ExtractTransactionTask { witnessBuffer.addBufferToData(witness); } } - const ouputCount = psbt + const outputCount = psbt .getGlobalValue(PsbtGlobal.OUTPUT_COUNT) - .chain((value) => this._valueParser.getUInt32LE(value.data)) + .chain((value) => this._valueParser.getVarint(value.data)) .orDefault(0); - transaction.addBufferToData(encodeVarint(ouputCount).extract()!); - for (let o = 0; o < ouputCount; o++) { + transaction.addBufferToData(encodeVarint(outputCount).extract()!); + for (let o = 0; o < outputCount; o++) { const amount = psbt .getOutputValue(o, PsbtOut.AMOUNT) - .chain((value) => this._valueParser.getVarint(value.data)) - .orDefault(0); + .chain((value) => this._valueParser.getInt64LE(value.data)) + .orDefault(0n); const script = psbt .getOutputValue(o, PsbtOut.SCRIPT) .mapOrDefault((v) => v.data, Buffer.from([])); - transaction.addBufferToData(encodeVarint(amount).extract()!); - transaction.encodeInLVFromBuffer(script); + transaction.add64BitIntToData(amount, false); + transaction.addBufferToData(encodeVarint(script.length).extract()!); + transaction.addBufferToData(script); } transaction.addBufferToData(witnessBuffer.build()); const locktime = psbt 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 index 73b688145..230668da3 100644 --- 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 @@ -162,7 +162,7 @@ describe("UpdatePsbtTask", () => { ], [ new Key(PsbtIn.FINAL_SCRIPTSIG).toHexaString(), - new Value(Uint8Array.from([0x01, 0x21, 0x01, 0x42])), + new Value(Uint8Array.from([0x01, 0x42, 0x01, 0x21])), ], ]), ], @@ -242,7 +242,7 @@ describe("UpdatePsbtTask", () => { ], [ new Key(PsbtIn.FINAL_SCRIPTWITNESS).toHexaString(), - new Value(Uint8Array.from([0x02, 0x01, 0x21, 0x01, 0x42])), + new Value(Uint8Array.from([0x02, 0x01, 0x42, 0x01, 0x21])), ], [ new Key(PsbtIn.FINAL_SCRIPTSIG).toHexaString(), 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 4873c35a0..66c3a22e6 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 @@ -29,6 +29,9 @@ type UpdatePsbtTaskArgs = { signatures: PsbtSignature[]; }; +const SCHORR_SIG_LENGTH = 64; +const SCHORR_SIG_LENGTH_WITH_EXTRA_BYTE = 65; + export class UpdatePsbtTask { constructor( private readonly _args: UpdatePsbtTaskArgs, @@ -118,6 +121,12 @@ export class UpdatePsbtTask { `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, @@ -125,11 +134,11 @@ export class UpdatePsbtTask { ); }); } else { - psbt.setKeyDataInputValue( + psbt.setInputValue( psbtSignature.inputIndex, PsbtIn.PARTIAL_SIG, - psbtSignature.signature, - new Value(psbtSignature.pubkey), + new Value(psbtSignature.signature), + psbtSignature.pubkey, ); } }); @@ -325,7 +334,6 @@ export class UpdatePsbtTask { inputIndex: number, ): InternalPsbt { const psbt = fromPsbt; - // Taproot input const maybeSignature = psbt.getInputValue(inputIndex, PsbtIn.TAP_KEY_SIG); if (maybeSignature.isNothing()) { throw Error("No signature for taproot input " + inputIndex); @@ -335,13 +343,15 @@ export class UpdatePsbtTask { Uint8Array.from([]), ); - if (signature.length != 64 && signature.length != 65) { + 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.addBufferToData( - encodeVarint(1).mapOrDefault((v) => v, Uint8Array.from([1])), - ); + witnessBufferBuilder.add8BitUIntToData(0x01); witnessBufferBuilder.encodeInLVFromBuffer(signature); psbt.setInputValue( inputIndex, 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 a95062746..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 @@ -186,12 +186,7 @@ describe("Psbt", () => { const psbt = new Psbt(new Map(), [new Map()]); const value = new Value(Uint8Array.of(0x03)); // when - psbt.setKeyDataInputValue( - 0, - PsbtIn.PARTIAL_SIG, - Uint8Array.from([0x42]), - value, - ); + psbt.setInputValue(0, PsbtIn.PARTIAL_SIG, value, Uint8Array.from([0x42])); // then expect(psbt).toStrictEqual( new Psbt( 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 b0742061d..f5dcfab35 100644 --- a/packages/signer/signer-btc/src/internal/psbt/model/Psbt.ts +++ b/packages/signer/signer-btc/src/internal/psbt/model/Psbt.ts @@ -114,18 +114,14 @@ 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); - } - - setKeyDataInputValue( + setInputValue( inputIndex: number, - keyIn: PsbtIn, - keyData: Uint8Array, + key: PsbtIn, value: Value, + keyData?: Uint8Array, ) { this.inputMaps[inputIndex]?.set( - new Key(keyIn, keyData).toHexaString(), + new Key(key, keyData).toHexaString(), value, ); } 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 a804c9d30..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 @@ -15,6 +15,10 @@ export class DefaultValueParser implements ValueParser { ); } + 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 6411b7cda..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 @@ -3,5 +3,6 @@ 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; } From eae62c97474d7a213d570bc790f6a9ada6411d81 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Fri, 17 Jan 2025 13:12:53 +0100 Subject: [PATCH 7/7] :bookmark: (signer-btc): Changeset --- .changeset/selfish-maps-rhyme.md | 5 +++++ packages/signer/signer-btc/package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/selfish-maps-rhyme.md 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/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",