diff --git a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts index 4e61de12a1..eb64859da6 100644 --- a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts @@ -233,18 +233,4 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { return utxo; }); } - - private computeAddressesIndexFromParsed(): void { - const sender = this.transaction._fromAddresses; - if (!sender || sender.length === 0) return; - - this.transaction._utxos.forEach((utxo) => { - if (utxo.addresses && utxo.addresses.length > 0) { - const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); - utxo.addressesIndex = sender.map((senderAddr) => - utxoAddresses.findIndex((utxoAddr) => Buffer.compare(Buffer.from(utxoAddr), Buffer.from(senderAddr)) === 0) - ); - } - }); - } } diff --git a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts index 42a6a702f8..4ea3696edc 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts @@ -230,18 +230,4 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { }; }); } - - private computeAddressesIndexFromParsed(): void { - const sender = this.transaction._fromAddresses; - if (!sender || sender.length === 0) return; - - this.transaction._utxos.forEach((utxo) => { - if (utxo.addresses && utxo.addresses.length > 0) { - const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); - utxo.addressesIndex = sender.map((senderAddr) => - utxoAddresses.findIndex((utxoAddr) => Buffer.compare(Buffer.from(utxoAddr), Buffer.from(senderAddr)) === 0) - ); - } - }); - } } diff --git a/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts index c4e91cb336..a925cb5da9 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts @@ -268,27 +268,4 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder { return utxo; }); } - - /** - * Compute proper addressesIndex from parsed transaction's sigIndicies. - * Following AVAX P approach: addressesIndex[senderIdx] = position of sender in UTXO addresses. - * - * For parsed transactions: - * - sigIndicies tells us which UTXO positions need to sign for each slot - * - We use output addresses as proxy for UTXO addresses - * - We compute addressesIndex as sender -> utxo position mapping - */ - private computeAddressesIndexFromParsed(): void { - const sender = this.transaction._fromAddresses; - if (!sender || sender.length === 0) return; - - this.transaction._utxos.forEach((utxo) => { - if (utxo.addresses && utxo.addresses.length > 0) { - const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); - utxo.addressesIndex = sender.map((senderAddr) => - utxoAddresses.findIndex((utxoAddr) => Buffer.compare(Buffer.from(utxoAddr), Buffer.from(senderAddr)) === 0) - ); - } - }); - } } diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index 5cbd74a9d5..37ba420c50 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -105,12 +105,17 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder { * Compute addressesIndex for UTXOs following AVAX P approach. * addressesIndex[senderIdx] = position of sender[senderIdx] in UTXO's address list * + * IMPORTANT: UTXO addresses are sorted lexicographically by byte value to match + * on-chain storage order. The API may return addresses in arbitrary order, but + * on-chain UTXOs always store addresses in sorted order. + * * Example: * A = user key, B = hsm key, C = backup key * sender (bitgoAddresses) = [ A, B, C ] - * utxo.addresses (IMS addresses) = [ B, C, A ] - * addressesIndex = [ 2, 0, 1 ] - * (sender[0]=A is at position 2 in UTXO, sender[1]=B is at position 0, etc.) + * utxo.addresses (from API) = [ B, C, A ] + * sorted utxo.addresses = [ A, B, C ] (sorted by hex value) + * addressesIndex = [ 0, 1, 2 ] + * (sender[0]=A is at position 0 in sorted UTXO, sender[1]=B is at position 1, etc.) * * @protected */ @@ -123,7 +128,10 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder { } if (utxo.addresses && utxo.addresses.length > 0) { - const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); + const sortedAddresses = utils.sortAddressesByHex(utxo.addresses); + utxo.addresses = sortedAddresses; + + const utxoAddresses = sortedAddresses.map((a) => utils.parseAddress(a)); utxo.addressesIndex = sender.map((a) => utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0) ); @@ -131,6 +139,33 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder { }); } + /** + * Compute addressesIndex from parsed transaction data. + * Similar to computeAddressesIndex() but used when parsing existing transactions + * via initBuilder(). + * + * IMPORTANT: UTXO addresses are sorted lexicographically by byte value to match + * on-chain storage order, ensuring consistency with fresh builds. + * + * @protected + */ + protected computeAddressesIndexFromParsed(): void { + const sender = this.transaction._fromAddresses; + if (!sender || sender.length === 0) return; + + this.transaction._utxos.forEach((utxo) => { + if (utxo.addresses && utxo.addresses.length > 0) { + const sortedAddresses = utils.sortAddressesByHex(utxo.addresses); + utxo.addresses = sortedAddresses; + + const utxoAddresses = sortedAddresses.map((a) => utils.parseAddress(a)); + utxo.addressesIndex = sender.map((senderAddr) => + utxoAddresses.findIndex((utxoAddr) => Buffer.compare(Buffer.from(utxoAddr), Buffer.from(senderAddr)) === 0) + ); + } + }); + } + /** * Validate UTXOs have consistent addresses. * Note: UTXO threshold can differ from transaction threshold - each UTXO has its own diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index 97821fa492..b4db2dba82 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -374,6 +374,32 @@ export class Utils implements BaseUtils { return new Id(Buffer.from(value, 'hex')); } + /** + * Sort addresses lexicographically by their byte representation. + * This matches how addresses are stored on-chain in Avalanche/Flare P-chain UTXOs. + * @param addresses - Array of bech32 address strings (e.g., "P-costwo1...") + * @returns Array of addresses sorted by hex value + */ + public sortAddressesByHex(addresses: string[]): string[] { + return [...addresses].sort((a, b) => { + const aHex = this.parseAddress(a).toString('hex'); + const bHex = this.parseAddress(b).toString('hex'); + return aHex.localeCompare(bHex); + }); + } + + /** + * Sort address buffers lexicographically by their byte representation. + * This matches how addresses are stored on-chain in Avalanche/Flare P-chain UTXOs. + * @param addressBuffers - Array of address byte buffers + * @returns Array of address buffers sorted by hex value + */ + public sortAddressBuffersByHex(addressBuffers: Buffer[]): Buffer[] { + return [...addressBuffers].sort((a, b) => { + return a.toString('hex').localeCompare(b.toString('hex')); + }); + } + /** * Recover public key from signature * @param messageHash - The SHA256 hash of the message (e.g., signablePayload) @@ -496,6 +522,11 @@ export class Utils implements BaseUtils { /** * Convert DecodedUtxoObj to native FlareJS Utxo object * This is the reverse of utxoToDecoded + * + * IMPORTANT: Addresses are sorted lexicographically by byte value to match + * on-chain storage order. The API may return addresses in arbitrary order, but + * on-chain UTXOs always store addresses in sorted order. + * * @param decoded - DecodedUtxoObj to convert * @param assetId - Asset ID as cb58 encoded string * @returns Native FlareJS Utxo object @@ -507,9 +538,14 @@ export class Utils implements BaseUtils { // Parse addresses from bech32 strings to byte buffers const addressBytes = decoded.addresses.map((addr) => this.parseAddress(addr)); - // Create OutputOwners with locktime, threshold, and addresses + // Sort addresses lexicographically by byte value to match on-chain order + // This is critical because the P-chain stores addresses in sorted order, + // and sigIndices must reference the correct positions. + const sortedAddressBytes = this.sortAddressBuffersByHex(addressBytes); + + // Create OutputOwners with locktime, threshold, and SORTED addresses const locktime = decoded.locktime ? BigInt(decoded.locktime) : BigInt(0); - const outputOwners = OutputOwners.fromNative(addressBytes, locktime, decoded.threshold); + const outputOwners = OutputOwners.fromNative(sortedAddressBytes, locktime, decoded.threshold); // Create TransferOutput with amount and owners const amount = BigInt(decoded.amount); diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index 8a09033fd7..9790587b90 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -148,6 +148,141 @@ describe('Flrp Export In P Tx Builder', () => { }); }); + describe('UTXO address sorting fix - addresses in non-sorted order for ExportInP', () => { + /** + * This test suite verifies the fix for the address ordering bug in ExportInP. + * + * The issue: When the API returns UTXO addresses in a different order than how they're + * stored on-chain (lexicographically sorted by byte value), the sigIndices would be + * computed incorrectly, causing signature verification to fail. + * + * The fix: Sort UTXO addresses before computing addressesIndex to match on-chain order. + */ + + // Helper to create UTXO with specific address order + const createUtxoWithAddressOrder = (utxo: (typeof testData.utxos)[0], addresses: string[]) => ({ + ...utxo, + addresses: addresses, + }); + + it('should correctly sort UTXO addresses when building ExportInP transaction', async () => { + // Create UTXOs with addresses in reversed order (simulating API returning unsorted) + const reversedUtxos = testData.utxos.map((utxo) => + createUtxoWithAddressOrder(utxo, [...utxo.addresses].reverse()) + ); + + 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(reversedUtxos); + + // Should not throw - the fix ensures addresses are sorted before computing sigIndices + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + txJson.type.should.equal(22); // Export In P type + txJson.threshold.should.equal(2); + }); + + it('should produce same transaction hex regardless of input UTXO address order for ExportInP', async () => { + // Build with original address order + const txBuilder1 = 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); + + const tx1 = await txBuilder1.build(); + const hex1 = tx1.toBroadcastFormat(); + + // Build with reversed address order in UTXOs + const reversedUtxos = testData.utxos.map((utxo) => + createUtxoWithAddressOrder(utxo, [...utxo.addresses].reverse()) + ); + + const txBuilder2 = factory + .getExportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .amount(testData.amount) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(reversedUtxos); + + const tx2 = await txBuilder2.build(); + const hex2 = tx2.toBroadcastFormat(); + + // Both should produce the same hex since addresses get sorted + hex1.should.equal(hex2); + }); + + it('should handle signing correctly with unsorted UTXO addresses for ExportInP', async () => { + const reversedUtxos = testData.utxos.map((utxo) => + createUtxoWithAddressOrder(utxo, [...utxo.addresses].reverse()) + ); + + 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(reversedUtxos); + + txBuilder.sign({ key: testData.privateKeys[2] }); + txBuilder.sign({ key: testData.privateKeys[0] }); + + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + // Should have signatures after signing (count depends on UTXO thresholds) + txJson.signatures.length.should.be.greaterThan(0); + tx.toBroadcastFormat().should.be.a.String(); + }); + + it('should produce valid signed transaction matching expected output with unsorted addresses for ExportInP', async () => { + const reversedUtxos = testData.utxos.map((utxo) => + createUtxoWithAddressOrder(utxo, [...utxo.addresses].reverse()) + ); + + 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(reversedUtxos); + + txBuilder.sign({ key: testData.privateKeys[2] }); + txBuilder.sign({ key: testData.privateKeys[0] }); + + const tx = await txBuilder.build(); + + // The signed tx should match the expected fullSigntxHex from testData + tx.toBroadcastFormat().should.equal(testData.fullSigntxHex); + tx.id.should.equal(testData.txhash); + }); + }); + describe('addressesIndex extraction and signature ordering', () => { it('should extract addressesIndex from parsed transaction inputs', async () => { const txBuilder = factory.from(testData.halfSigntxHex); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts index c0d08d4554..a9fb62dedf 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts @@ -209,6 +209,141 @@ describe('Flrp Import In C Tx Builder', () => { }); }); + describe('UTXO address sorting fix - addresses in non-sorted order for ImportInC', () => { + /** + * This test suite verifies the fix for the address ordering bug in ImportInC. + * + * The issue: When the API returns UTXO addresses in a different order than how they're + * stored on-chain (lexicographically sorted by byte value), the sigIndices would be + * computed incorrectly, causing signature verification to fail. + * + * The fix: Sort UTXO addresses before computing addressesIndex to match on-chain order. + */ + + // Helper to create UTXO with specific address order + const createUtxoWithAddressOrder = (utxo: (typeof testData.utxos)[0], addresses: string[]) => ({ + ...utxo, + addresses: addresses, + }); + + it('should correctly sort UTXO addresses when building ImportInC transaction', async () => { + // Create UTXOs with addresses in reversed order (simulating API returning unsorted) + const reversedUtxos = testData.utxos.map((utxo) => + createUtxoWithAddressOrder(utxo, [...utxo.addresses].reverse()) + ); + + const txBuilder = factory + .getImportInCBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .to(testData.to) + .externalChainId(testData.sourceChainId) + .fee(testData.fee) + .context(testData.context) + .decodedUtxos(reversedUtxos); + + // Should not throw - the fix ensures addresses are sorted before computing sigIndices + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + // Verify transaction built successfully + txJson.threshold.should.equal(2); + tx.toBroadcastFormat().should.be.a.String(); + }); + + it('should produce same transaction hex regardless of input UTXO address order for ImportInC', async () => { + // Build with original address order + const txBuilder1 = factory + .getImportInCBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .to(testData.to) + .externalChainId(testData.sourceChainId) + .fee(testData.fee) + .context(testData.context) + .decodedUtxos(testData.utxos); + + const tx1 = await txBuilder1.build(); + const hex1 = tx1.toBroadcastFormat(); + + // Build with reversed address order in UTXOs + const reversedUtxos = testData.utxos.map((utxo) => + createUtxoWithAddressOrder(utxo, [...utxo.addresses].reverse()) + ); + + const txBuilder2 = factory + .getImportInCBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .to(testData.to) + .externalChainId(testData.sourceChainId) + .fee(testData.fee) + .context(testData.context) + .decodedUtxos(reversedUtxos); + + const tx2 = await txBuilder2.build(); + const hex2 = tx2.toBroadcastFormat(); + + // Both should produce the same hex since addresses get sorted + hex1.should.equal(hex2); + }); + + it('should handle signing correctly with unsorted UTXO addresses for ImportInC', async () => { + const reversedUtxos = testData.utxos.map((utxo) => + createUtxoWithAddressOrder(utxo, [...utxo.addresses].reverse()) + ); + + const txBuilder = factory + .getImportInCBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .to(testData.to) + .externalChainId(testData.sourceChainId) + .fee(testData.fee) + .context(testData.context) + .decodedUtxos(reversedUtxos); + + txBuilder.sign({ key: testData.privateKeys[2] }); + txBuilder.sign({ key: testData.privateKeys[0] }); + + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + // Should have 2 signatures after signing + txJson.signatures.length.should.equal(2); + }); + + it('should produce valid signed transaction matching expected output with unsorted addresses for ImportInC', async () => { + const reversedUtxos = testData.utxos.map((utxo) => + createUtxoWithAddressOrder(utxo, [...utxo.addresses].reverse()) + ); + + const txBuilder = factory + .getImportInCBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .to(testData.to) + .externalChainId(testData.sourceChainId) + .fee(testData.fee) + .context(testData.context) + .decodedUtxos(reversedUtxos); + + txBuilder.sign({ key: testData.privateKeys[2] }); + txBuilder.sign({ key: testData.privateKeys[0] }); + + const tx = await txBuilder.build(); + + // The signed tx should match the expected fullSigntxHex from testData + tx.toBroadcastFormat().should.equal(testData.fullSigntxHex); + tx.id.should.equal(testData.txhash); + }); + }); + 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); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts index c28a258c84..f20730b4dc 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts @@ -253,6 +253,215 @@ describe('Flrp Import In P Tx Builder', () => { }); }); + describe('UTXO address sorting fix - addresses in non-sorted order', () => { + /** + * This test suite verifies the fix for the address ordering bug. + * + * The issue: When the API returns UTXO addresses in a different order than how they're + * stored on-chain (lexicographically sorted by byte value), the sigIndices would be + * computed incorrectly, causing signature verification to fail. + * + * The fix: Sort UTXO addresses before computing addressesIndex to match on-chain order. + * + * We use the existing testData addresses but create UTXOs with different address orderings + * to simulate the failed transaction scenario. + */ + + // Create UTXOs with addresses in different orders to test sorting + const createUtxoWithAddressOrder = (addresses: string[]) => ({ + outputID: 7, + amount: '50000000', + txid: testData.utxos[0].txid, + threshold: 2, + addresses: addresses, + outputidx: '0', + locktime: '0', + }); + + it('should correctly sort UTXO addresses when building transaction', async () => { + // Create UTXO with addresses in reversed order (simulating API returning unsorted) + const reversedAddresses = [...testData.utxos[0].addresses].reverse(); + const utxoWithReversedAddresses = [createUtxoWithAddressOrder(reversedAddresses)]; + + const txBuilder = factory + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(utxoWithReversedAddresses); + + // Should not throw - the fix ensures addresses are sorted before computing sigIndices + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + txJson.type.should.equal(23); // Import type + txJson.threshold.should.equal(2); + }); + + it('should produce same transaction hex regardless of input UTXO address order', async () => { + // Build with original address order + const txBuilder1 = factory + .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 tx1 = await txBuilder1.build(); + const hex1 = tx1.toBroadcastFormat(); + + // Build with reversed address order in UTXO + const reversedAddresses = [...testData.utxos[0].addresses].reverse(); + const utxoWithReversedAddresses = [createUtxoWithAddressOrder(reversedAddresses)]; + + const txBuilder2 = factory + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(utxoWithReversedAddresses); + + const tx2 = await txBuilder2.build(); + const hex2 = tx2.toBroadcastFormat(); + + // Both should produce the same hex since addresses get sorted + hex1.should.equal(hex2); + }); + + it('should handle multiple UTXOs with different address orders', async () => { + // Create multiple UTXOs with addresses in different orders + const addresses = testData.utxos[0].addresses; + const multipleUtxos = [ + { + outputID: 7, + amount: '30000000', + txid: testData.utxos[0].txid, + threshold: 2, + addresses: [addresses[0], addresses[1], addresses[2]], // Original order + outputidx: '0', + locktime: '0', + }, + { + outputID: 7, + amount: '20000000', + txid: '2bK27hnZ8FaR33bRBs6wrb1PkjJfseZrn3nD4LckW9gCwTrmGX', + threshold: 2, + addresses: [addresses[2], addresses[0], addresses[1]], // Different order + outputidx: '0', + locktime: '0', + }, + ]; + + const txBuilder = factory + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(multipleUtxos); + + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + // Should have 2 inputs from the 2 UTXOs + txJson.inputs.length.should.equal(2); + txJson.type.should.equal(23); + }); + + it('should produce valid transaction that can be parsed and rebuilt with unsorted addresses', async () => { + const reversedAddresses = [...testData.utxos[0].addresses].reverse(); + const utxoWithReversedAddresses = [createUtxoWithAddressOrder(reversedAddresses)]; + + const txBuilder = factory + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(utxoWithReversedAddresses); + + const tx = await txBuilder.build(); + const txHex = tx.toBroadcastFormat(); + + // Parse the transaction + const parsedBuilder = new TransactionBuilderFactory(coins.get('tflrp')).from(txHex); + const parsedTx = await parsedBuilder.build(); + const parsedHex = parsedTx.toBroadcastFormat(); + + // Should produce identical hex + parsedHex.should.equal(txHex); + }); + + it('should handle signing correctly with unsorted UTXO addresses', async () => { + const reversedAddresses = [...testData.utxos[0].addresses].reverse(); + const utxoWithReversedAddresses = [createUtxoWithAddressOrder(reversedAddresses)]; + + const txBuilder = factory + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(utxoWithReversedAddresses); + + txBuilder.sign({ key: testData.privateKeys[2] }); + txBuilder.sign({ key: testData.privateKeys[0] }); + + const tx = await txBuilder.build(); + const txJson = tx.toJson(); + + // Should have 2 signatures after signing + txJson.signatures.length.should.equal(2); + }); + + it('should produce valid signed transaction matching original test data signing flow', async () => { + // This test verifies that with unsorted UTXO addresses, we still get the expected signed tx + const reversedAddresses = [...testData.utxos[0].addresses].reverse(); + const utxoWithReversedAddresses = [createUtxoWithAddressOrder(reversedAddresses)]; + + const txBuilder = factory + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.corethAddresses) + .to(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .feeState(testData.feeState) + .context(testData.context) + .decodedUtxos(utxoWithReversedAddresses); + + txBuilder.sign({ key: testData.privateKeys[2] }); + txBuilder.sign({ key: testData.privateKeys[0] }); + + const tx = await txBuilder.build(); + + // The signed tx should match the expected signedHex from testData + tx.toBroadcastFormat().should.equal(testData.signedHex); + tx.id.should.equal(testData.txhash); + }); + }); + 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')) diff --git a/modules/sdk-coin-flrp/test/unit/lib/utils.ts b/modules/sdk-coin-flrp/test/unit/lib/utils.ts index 2e64a63615..57916a3d36 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/utils.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/utils.ts @@ -652,6 +652,122 @@ describe('Utils', function () { }); }); + describe('sortAddressesByHex', function () { + it('should sort addresses lexicographically by byte value', function () { + // Use addresses from IMPORT_IN_P test data (these are valid bech32 addresses) + // UTXO addresses in test data order: [xv5mulgpe..., 06gc5h5qs..., cueygd7fd...] + const unsortedAddresses = IMPORT_IN_P.utxos[0].addresses; + + const sortedAddresses = utils.sortAddressesByHex(unsortedAddresses); + + // Verify the result is sorted by comparing hex values + const sortedHexes = sortedAddresses.map((addr) => utils.parseAddress(addr).toString('hex')); + for (let i = 1; i < sortedHexes.length; i++) { + assert.ok( + sortedHexes[i - 1].localeCompare(sortedHexes[i]) <= 0, + `Address at index ${i - 1} should be <= address at index ${i}` + ); + } + + // The sorted result should have the same addresses, just reordered + assert.strictEqual(sortedAddresses.length, unsortedAddresses.length); + unsortedAddresses.forEach((addr) => { + assert.ok(sortedAddresses.includes(addr), `Sorted array should contain ${addr}`); + }); + }); + + it('should produce consistent sorting regardless of input order', function () { + const addresses = [...IMPORT_IN_P.utxos[0].addresses]; + const reversed = [...addresses].reverse(); + const shuffled = [addresses[1], addresses[2], addresses[0]]; + + const sorted1 = utils.sortAddressesByHex(addresses); + const sorted2 = utils.sortAddressesByHex(reversed); + const sorted3 = utils.sortAddressesByHex(shuffled); + + // All should produce the same sorted result + assert.deepStrictEqual(sorted1, sorted2); + assert.deepStrictEqual(sorted2, sorted3); + }); + + it('should not modify original array', function () { + const original = [...IMPORT_IN_P.utxos[0].addresses]; + const originalCopy = [...original]; + + utils.sortAddressesByHex(original); + + assert.deepStrictEqual(original, originalCopy); + }); + + it('should handle single address array', function () { + const singleAddr = [IMPORT_IN_P.pAddresses[0]]; + const sorted = utils.sortAddressesByHex(singleAddr); + + assert.strictEqual(sorted.length, 1); + assert.strictEqual(sorted[0], singleAddr[0]); + }); + + it('should handle empty array', function () { + const sorted = utils.sortAddressesByHex([]); + assert.strictEqual(sorted.length, 0); + }); + }); + + describe('sortAddressBuffersByHex', function () { + it('should sort address buffers lexicographically by byte value', function () { + // Parse addresses from test data + const addresses = IMPORT_IN_P.utxos[0].addresses; + const buffers = addresses.map((addr) => utils.parseAddress(addr)); + + // Shuffle to ensure unsorted + const unsortedBuffers = [buffers[2], buffers[0], buffers[1]]; + const sortedBuffers = utils.sortAddressBuffersByHex(unsortedBuffers); + + // Verify the result is sorted by comparing hex values + for (let i = 1; i < sortedBuffers.length; i++) { + const prevHex = sortedBuffers[i - 1].toString('hex'); + const currHex = sortedBuffers[i].toString('hex'); + assert.ok(prevHex.localeCompare(currHex) <= 0, `Buffer at index ${i - 1} should be <= buffer at index ${i}`); + } + }); + + it('should produce consistent sorting regardless of input order', function () { + const addresses = IMPORT_IN_P.utxos[0].addresses; + const buffers = addresses.map((addr) => utils.parseAddress(addr)); + + const order1 = [buffers[0], buffers[1], buffers[2]]; + const order2 = [buffers[2], buffers[1], buffers[0]]; + const order3 = [buffers[1], buffers[2], buffers[0]]; + + const sorted1 = utils.sortAddressBuffersByHex(order1); + const sorted2 = utils.sortAddressBuffersByHex(order2); + const sorted3 = utils.sortAddressBuffersByHex(order3); + + // All should produce the same sorted result + assert.deepStrictEqual( + sorted1.map((b) => b.toString('hex')), + sorted2.map((b) => b.toString('hex')) + ); + assert.deepStrictEqual( + sorted2.map((b) => b.toString('hex')), + sorted3.map((b) => b.toString('hex')) + ); + }); + + it('should not modify original array', function () { + const addresses = IMPORT_IN_P.utxos[0].addresses; + const original = addresses.map((addr) => utils.parseAddress(addr)); + const originalHexes = original.map((b) => b.toString('hex')); + + utils.sortAddressBuffersByHex(original); + + assert.deepStrictEqual( + original.map((b) => b.toString('hex')), + originalHexes + ); + }); + }); + describe('isTransactionOf', function () { const factory = new TransactionBuilderFactory(coins.get('tflrp')); const utilsInstance = new Utils();