diff --git a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts index fece22b99f..55932fcf4d 100644 --- a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts @@ -76,26 +76,15 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { const txCredentials = credentials.length > 0 ? credentials - : exportTx.baseTx.inputs.map((input, inputIdx) => { - const transferInput = input.input as TransferInput; - const inputThreshold = transferInput.sigIndicies().length || this.transaction._threshold; - - const utxo = this.transaction._utxos[inputIdx]; - - if (inputThreshold === this.transaction._threshold) { - return this.createCredentialForUtxo(utxo, this.transaction._threshold); - } else { - const sigSlots: ReturnType[] = []; - for (let i = 0; i < inputThreshold; i++) { - sigSlots.push(utils.createNewSig('')); - } - return new Credential(sigSlots); - } + : this.transaction._utxos.map((utxo) => { + const utxoThreshold = utxo.threshold || this.transaction._threshold; + return this.createCredentialForUtxo(utxo, utxoThreshold); }); - const addressMaps = txCredentials.map((credential, credIdx) => - this.createAddressMapForUtxo(this.transaction._utxos[credIdx], this.transaction._threshold) - ); + const addressMaps = this.transaction._utxos.map((utxo) => { + const utxoThreshold = utxo.threshold || this.transaction._threshold; + return this.createAddressMapForUtxo(utxo, utxoThreshold); + }); const unsignedTx = new UnsignedTx(exportTx, [], new FlareUtils.AddressMaps(addressMaps), txCredentials); this.transaction.setTransaction(unsignedTx); @@ -167,11 +156,32 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { this.transaction._context ); - this.transaction.setTransaction(exportTx); + const flareUnsignedTx = exportTx as UnsignedTx; + const innerTx = flareUnsignedTx.getTx() as pvmSerial.ExportTx; + + const utxosWithIndex = innerTx.baseTx.inputs.map((input, idx) => { + const transferInput = input.input as TransferInput; + const addressesIndex = transferInput.sigIndicies(); + return { + ...this.transaction._utxos[idx], + addressesIndex, + addresses: [], + threshold: addressesIndex.length || this.transaction._utxos[idx].threshold, + }; + }); + + const txCredentials = utxosWithIndex.map((utxo) => this.createCredentialForUtxo(utxo, utxo.threshold)); + + const addressMaps = utxosWithIndex.map((utxo) => this.createAddressMapForUtxo(utxo, utxo.threshold)); + + const fixedUnsignedTx = new UnsignedTx(innerTx, [], new FlareUtils.AddressMaps(addressMaps), txCredentials); + + this.transaction.setTransaction(fixedUnsignedTx); } /** * Recover UTXOs from inputs + * Extract addressesIndex from sigIndicies for proper signature ordering * @param inputs Array of TransferableInput * @returns Array of decoded UTXO objects */ @@ -179,17 +189,18 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { return inputs.map((input) => { const utxoId = input.utxoID; const transferInput = input.input as TransferInput; - const inputThreshold = transferInput.sigIndicies().length; - return { + const addressesIndex = transferInput.sigIndicies(); + + const utxo: DecodedUtxoObj = { outputID: SECP256K1_Transfer_Output, amount: input.amount().toString(), txid: utils.cb58Encode(Buffer.from(utxoId.txID.toBytes())), outputidx: utxoId.outputIdx.value().toString(), - threshold: inputThreshold || this.transaction._threshold, - addresses: this.transaction._fromAddresses.map((addr) => - utils.addressToString(this.transaction._network.hrp, this.transaction._network.alias, Buffer.from(addr)) - ), + threshold: addressesIndex.length || this.transaction._threshold, + addresses: [], + addressesIndex, }; + return utxo; }); } } diff --git a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts index 2a9eb86a1e..cf9dc88460 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts @@ -74,42 +74,48 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { fee: fee.toString(), }; - const firstUtxo = this.transaction._utxos[0]; - let addressMap: FlareUtils.AddressMap; - if ( - firstUtxo && - firstUtxo.addresses && - firstUtxo.addresses.length > 0 && - this.transaction._fromAddresses && - this.transaction._fromAddresses.length >= this.transaction._threshold - ) { - addressMap = this.createAddressMapForUtxo(firstUtxo, this.transaction._threshold); - } else { - addressMap = new FlareUtils.AddressMap(); - if (this.transaction._fromAddresses && this.transaction._fromAddresses.length >= this.transaction._threshold) { - this.transaction._fromAddresses.slice(0, this.transaction._threshold).forEach((addr, i) => { + const addressMaps = this.transaction._utxos.map((utxo) => { + const utxoThreshold = utxo.threshold || this.transaction._threshold; + if ( + utxo.addressesIndex && + utxo.addressesIndex.length >= utxoThreshold && + this.transaction._fromAddresses && + this.transaction._fromAddresses.length >= utxoThreshold + ) { + return this.createAddressMapForUtxo(utxo, utxoThreshold); + } + if ( + utxo.addresses && + utxo.addresses.length > 0 && + this.transaction._fromAddresses && + this.transaction._fromAddresses.length >= utxoThreshold + ) { + return this.createAddressMapForUtxo(utxo, utxoThreshold); + } + const addressMap = new FlareUtils.AddressMap(); + if (this.transaction._fromAddresses && this.transaction._fromAddresses.length >= utxoThreshold) { + this.transaction._fromAddresses.slice(0, utxoThreshold).forEach((addr, i) => { addressMap.set(new Address(addr), i); }); } else { const toAddress = new Address(output.address.toBytes()); addressMap.set(toAddress, 0); } - } + return addressMap; + }); - const addressMaps = new FlareUtils.AddressMaps([addressMap]); + const flareAddressMaps = new FlareUtils.AddressMaps(addressMaps); let txCredentials: Credential[]; if (credentials.length > 0) { txCredentials = credentials; } else { - const emptySignatures: ReturnType[] = []; - for (let i = 0; i < inputThreshold; i++) { - emptySignatures.push(utils.createNewSig('')); - } - txCredentials = [new Credential(emptySignatures)]; + txCredentials = this.transaction._utxos.map((utxo) => + this.createCredentialForUtxo(utxo, utxo.threshold || this.transaction._threshold) + ); } - const unsignedTx = new UnsignedTx(baseTx, [], addressMaps, txCredentials); + const unsignedTx = new UnsignedTx(baseTx, [], flareAddressMaps, txCredentials); this.transaction.setTransaction(unsignedTx); return this; @@ -175,7 +181,27 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { actualFeeNFlr ); - this.transaction.setTransaction(importTx); + const flareUnsignedTx = importTx as UnsignedTx; + const innerTx = flareUnsignedTx.getTx() as evmSerial.ImportTx; + + const utxosWithIndex = innerTx.importedInputs.map((input, idx) => { + const transferInput = input.input as TransferInput; + const addressesIndex = transferInput.sigIndicies(); + return { + ...this.transaction._utxos[idx], + addressesIndex, + addresses: [], + threshold: addressesIndex.length || this.transaction._utxos[idx].threshold, + }; + }); + + const txCredentials = utxosWithIndex.map((utxo) => this.createCredentialForUtxo(utxo, utxo.threshold)); + + const addressMaps = utxosWithIndex.map((utxo) => this.createAddressMapForUtxo(utxo, utxo.threshold)); + + const fixedUnsignedTx = new UnsignedTx(innerTx, [], new FlareUtils.AddressMaps(addressMaps), txCredentials); + + this.transaction.setTransaction(fixedUnsignedTx); } /** diff --git a/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts index a59fc63e78..65ca860155 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts @@ -95,11 +95,15 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder { const txCredentials = credentials.length > 0 ? credentials - : this.transaction._utxos.map((utxo) => this.createCredentialForUtxo(utxo, this.transaction._threshold)); - - const addressMaps = this.transaction._utxos.map((utxo) => - this.createAddressMapForUtxo(utxo, this.transaction._threshold) - ); + : this.transaction._utxos.map((utxo) => { + const utxoThreshold = utxo.threshold || this.transaction._threshold; + return this.createCredentialForUtxo(utxo, utxoThreshold); + }); + + const addressMaps = this.transaction._utxos.map((utxo) => { + const utxoThreshold = utxo.threshold || this.transaction._threshold; + return this.createAddressMapForUtxo(utxo, utxoThreshold); + }); const unsignedTx = new UnsignedTx(importTx, [], new FlareUtils.AddressMaps(addressMaps), txCredentials); @@ -186,7 +190,27 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder { this.transaction._context ); - this.transaction.setTransaction(importTx); + const flareUnsignedTx = importTx as UnsignedTx; + const innerTx = flareUnsignedTx.getTx() as pvmSerial.ImportTx; + + const utxosWithIndex = innerTx.ins.map((input, idx) => { + const transferInput = input.input as TransferInput; + const addressesIndex = transferInput.sigIndicies(); + return { + ...this.transaction._utxos[idx], + addressesIndex, + addresses: [], + threshold: addressesIndex.length || this.transaction._utxos[idx].threshold, + }; + }); + + const txCredentials = utxosWithIndex.map((utxo) => this.createCredentialForUtxo(utxo, utxo.threshold)); + + const addressMaps = utxosWithIndex.map((utxo) => this.createAddressMapForUtxo(utxo, utxo.threshold)); + + const fixedUnsignedTx = new UnsignedTx(innerTx, [], new FlareUtils.AddressMaps(addressMaps), txCredentials); + + this.transaction.setTransaction(fixedUnsignedTx); } /** diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index a0230b2f91..8a09033fd7 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -147,4 +147,86 @@ describe('Flrp Export In P Tx Builder', () => { err.message.should.be.equal('Private key cannot sign the transaction'); }); }); + + describe('addressesIndex extraction and signature ordering', () => { + it('should extract addressesIndex from parsed transaction inputs', async () => { + const txBuilder = factory.from(testData.halfSigntxHex); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + txJson.type.should.equal(22); + txJson.signatures.length.should.be.greaterThan(0); + }); + + it('should correctly handle fresh build with proper signature ordering', async () => { + const txBuilder = factory + .getExportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .amount(testData.amount) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(testData.utxos); + + txBuilder.sign({ key: testData.privateKeys[2] }); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + txJson.type.should.equal(22); + tx.toBroadcastFormat().should.be.a.String(); + }); + + it('should correctly build and sign with different UTXO address ordering', async () => { + const reorderedUtxos = testData.utxos.map((utxo) => ({ + ...utxo, + addresses: [testData.pAddresses[1], testData.pAddresses[2], testData.pAddresses[0]], + })); + + const txBuilder = factory + .getExportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .amount(testData.amount) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(reorderedUtxos); + + txBuilder.sign({ key: testData.privateKeys[2] }); + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + txJson.type.should.equal(22); + tx.toBroadcastFormat().should.be.a.String(); + }); + + it('should handle parse-sign-parse-sign flow correctly', async () => { + const txBuilder = factory + .getExportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .amount(testData.amount) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(testData.utxos); + + txBuilder.sign({ key: testData.privateKeys[2] }); + const halfSignedTx = await txBuilder.build(); + const halfSignedHex = halfSignedTx.toBroadcastFormat(); + + const txBuilder2 = factory.from(halfSignedHex); + txBuilder2.sign({ key: testData.privateKeys[0] }); + const fullSignedTx = await txBuilder2.build(); + const fullSignedJson = fullSignedTx.toJson(); + + fullSignedJson.type.should.equal(22); + fullSignedJson.signatures.length.should.be.greaterThan(0); + fullSignedTx.toBroadcastFormat().should.be.a.String(); + }); + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts index 0381983a67..c0d08d4554 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts @@ -208,4 +208,41 @@ describe('Flrp Import In C Tx Builder', () => { txJson.signatures.length.should.equal(0); }); }); + + describe('fresh build with different UTXO address order for ImportInC', () => { + it('should correctly complete full sign flow with different UTXO address order for ImportInC', async () => { + const builder1 = new TransactionBuilderFactory(coins.get('tflrp')).from(testData.unsignedHex); + const unsignedTx = await builder1.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + const builder2 = new TransactionBuilderFactory(coins.get('tflrp')).from(unsignedHex); + builder2.sign({ key: testData.privateKeys[2] }); + const halfSignedTx = await builder2.build(); + const halfSignedHex = halfSignedTx.toBroadcastFormat(); + + halfSignedTx.toJson().signatures.length.should.equal(1); + + const builder3 = new TransactionBuilderFactory(coins.get('tflrp')).from(halfSignedHex); + builder3.sign({ key: testData.privateKeys[0] }); + const fullSignedTx = await builder3.build(); + + fullSignedTx.toJson().signatures.length.should.equal(2); + + const txId = fullSignedTx.id; + txId.should.be.a.String(); + txId.length.should.be.greaterThan(0); + }); + + it('should handle ImportInC signing in different order and still produce valid tx', async () => { + const txBuilder = new TransactionBuilderFactory(coins.get('tflrp')).from(testData.unsignedHex); + + txBuilder.sign({ key: testData.privateKeys[0] }); + txBuilder.sign({ key: testData.privateKeys[2] }); + + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + txJson.signatures.length.should.equal(2); + }); + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts index 57abb3e9dd..58b3bf6d4f 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts @@ -252,4 +252,123 @@ describe('Flrp Import In P Tx Builder', () => { txJson.signatures.length.should.equal(0); }); }); + + describe('fresh build with different UTXO address order', () => { + it('should correctly set up addressMaps when UTXO addresses differ from fromAddresses order', async () => { + const txBuilder = new TransactionBuilderFactory(coins.get('tflrp')) + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(testData.utxos); + + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + txJson.type.should.equal(23); + txJson.threshold.should.equal(2); + }); + + it('should produce correct signatures when signing fresh build with different address order', async () => { + const txBuilder = new TransactionBuilderFactory(coins.get('tflrp')) + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(testData.utxos); + + txBuilder.sign({ key: testData.privateKeys[2] }); + txBuilder.sign({ key: testData.privateKeys[0] }); + + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + txJson.signatures.length.should.equal(2); + }); + + it('should produce matching tx when fresh build is parsed and rebuilt', async () => { + const freshBuilder = new TransactionBuilderFactory(coins.get('tflrp')) + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(testData.utxos); + + const freshTx = await freshBuilder.build(); + const freshHex = freshTx.toBroadcastFormat(); + + const parsedBuilder = new TransactionBuilderFactory(coins.get('tflrp')).from(freshHex); + const parsedTx = await parsedBuilder.build(); + const parsedHex = parsedTx.toBroadcastFormat(); + + parsedHex.should.equal(freshHex); + }); + + it('should correctly complete full sign flow with different UTXO address order', async () => { + // Step 1: Build fresh unsigned transaction + const builder1 = new TransactionBuilderFactory(coins.get('tflrp')) + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(testData.utxos); + + const unsignedTx = await builder1.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + const builder2 = new TransactionBuilderFactory(coins.get('tflrp')).from(unsignedHex); + builder2.sign({ key: testData.privateKeys[2] }); + const halfSignedTx = await builder2.build(); + const halfSignedHex = halfSignedTx.toBroadcastFormat(); + + halfSignedTx.toJson().signatures.length.should.equal(1); + + const builder3 = new TransactionBuilderFactory(coins.get('tflrp')).from(halfSignedHex); + builder3.sign({ key: testData.privateKeys[0] }); + const fullSignedTx = await builder3.build(); + + fullSignedTx.toJson().signatures.length.should.equal(2); + + const txId = fullSignedTx.id; + txId.should.be.a.String(); + txId.length.should.be.greaterThan(0); + }); + + it('should handle signing in different order and still produce valid tx', async () => { + const txBuilder = new TransactionBuilderFactory(coins.get('tflrp')) + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(testData.utxos); + + txBuilder.sign({ key: testData.privateKeys[0] }); + txBuilder.sign({ key: testData.privateKeys[2] }); + + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + txJson.signatures.length.should.equal(2); + }); + }); });