diff --git a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts index 31166f307a..5de922ea99 100644 --- a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts @@ -110,12 +110,10 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { this.transaction._rawSignedBytes = rawBytes; } - // Create proper UnsignedTx wrapper with credentials - const sortedAddresses = [...this.transaction._fromAddresses].sort((a, b) => Buffer.compare(a, b)); - // When credentials were extracted, use them directly to preserve existing signatures // Otherwise, create empty credentials with dynamic ordering based on addressesIndex // Match avaxp behavior: order depends on UTXO address positions + // Use centralized method for credential creation const txCredentials = credentials.length > 0 ? credentials @@ -126,39 +124,11 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { // Get UTXO for this input to determine addressesIndex const utxo = this.transaction._utxos[inputIdx]; - // either user (0) or recovery (2) - const firstIndex = this.recoverSigner ? 2 : 0; - const bitgoIndex = 1; - - // If UTXO has addresses, compute dynamic ordering - if (utxo && utxo.addresses && utxo.addresses.length > 0) { - const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); - const addressesIndex = this.transaction._fromAddresses.map((a) => - utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0) - ); - - // Dynamic ordering based on addressesIndex - let sigSlots: ReturnType[]; - if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) { - // Bitgo comes first: [zeros, userAddress] - sigSlots = [ - utils.createNewSig(''), - utils.createEmptySigWithAddress( - Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex') - ), - ]; - } else { - // User comes first: [userAddress, zeros] - sigSlots = [ - utils.createEmptySigWithAddress( - Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex') - ), - utils.createNewSig(''), - ]; - } - return new Credential(sigSlots); + // Use centralized method, but handle case where inputThreshold might differ + if (inputThreshold === this.transaction._threshold) { + return this.createCredentialForUtxo(utxo, this.transaction._threshold); } else { - // Fallback: use all zeros if no UTXO addresses available + // Fallback: use all zeros if threshold differs (shouldn't happen normally) const sigSlots: ReturnType[] = []; for (let i = 0; i < inputThreshold; i++) { sigSlots.push(utils.createNewSig('')); @@ -167,15 +137,13 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { } }); - // Create address maps for signing - one per input/credential - // Each address map contains all addresses mapped to their indices - const addressMaps = txCredentials.map(() => { - const addressMap = new FlareUtils.AddressMap(); - sortedAddresses.forEach((addr, i) => { - addressMap.set(new Address(addr), i); - }); - return addressMap; - }); + // Create AddressMaps based on signature slot order (matching credential order), not sorted addresses + // This matches the approach used in credentials: addressesIndex determines signature order + // AddressMaps should map addresses to signature slots in the same order as credentials + // Use centralized method for AddressMap creation + const addressMaps = txCredentials.map((credential, credIdx) => + this.createAddressMapForUtxo(this.transaction._utxos[credIdx], this.transaction._threshold) + ); // Always create a new UnsignedTx with properly structured credentials const unsignedTx = new UnsignedTx(exportTx, [], new FlareUtils.AddressMaps(addressMaps), txCredentials); @@ -227,15 +195,13 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { this.exportedOutputs() // exportedOutputs ); - // Create address maps for signing - one per input/credential - const sortedAddresses = [...this.transaction._fromAddresses].sort((a, b) => Buffer.compare(a, b)); - const addressMaps = credentials.map(() => { - const addressMap = new FlareUtils.AddressMap(); - sortedAddresses.forEach((addr, i) => { - addressMap.set(new Address(addr), i); - }); - return addressMap; - }); + // Create AddressMaps based on signature slot order (matching credential order), not sorted addresses + // This matches the approach used in credentials: addressesIndex determines signature order + // AddressMaps should map addresses to signature slots in the same order as credentials + // Use centralized method for AddressMap creation + const addressMaps = credentials.map((credential, credIdx) => + this.createAddressMapForUtxo(this.transaction._utxos[credIdx], this.transaction._threshold) + ); // Create unsigned transaction const unsignedTx = new UnsignedTx( @@ -316,39 +282,15 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { // Create credential with empty signatures for slot identification // Match avaxp behavior: dynamic ordering based on addressesIndex from UTXO - const hasAddresses = - this.transaction._fromAddresses && this.transaction._fromAddresses.length >= this.transaction._threshold; - - if (!hasAddresses) { - // If addresses not available, use all zeros - const emptySignatures = sigIndices.map(() => utils.createNewSig('')); - credentials.push(new Credential(emptySignatures)); + // Use centralized method for credential creation + // Note: Use utxoThreshold if it differs from transaction threshold (should be rare) + const thresholdToUse = + utxoThreshold === this.transaction._threshold ? this.transaction._threshold : utxoThreshold; + if (thresholdToUse === this.transaction._threshold) { + credentials.push(this.createCredentialForUtxo(utxo, thresholdToUse)); } else { - // Compute addressesIndex: position of each _fromAddresses in UTXO's address list - const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); - const addressesIndex = this.transaction._fromAddresses.map((a) => - utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0) - ); - - // either user (0) or recovery (2) - const firstIndex = this.recoverSigner ? 2 : 0; - const bitgoIndex = 1; - - // Dynamic ordering based on addressesIndex - let emptySignatures: ReturnType[]; - if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) { - // Bitgo comes first in signature order: [zeros, userAddress] - emptySignatures = [ - utils.createNewSig(''), - utils.createEmptySigWithAddress(Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex')), - ]; - } else { - // User comes first in signature order: [userAddress, zeros] - emptySignatures = [ - utils.createEmptySigWithAddress(Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex')), - utils.createNewSig(''), - ]; - } + // Fallback: use all zeros if threshold differs (shouldn't happen normally) + const emptySignatures = sigIndices.map(() => utils.createNewSig('')); credentials.push(new Credential(emptySignatures)); } } diff --git a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts index aafbcab965..42b4baf260 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts @@ -87,9 +87,37 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { const inputThreshold = firstInput.sigIndicies().length || this.transaction._threshold; this.transaction._threshold = inputThreshold; - // Create proper UnsignedTx wrapper with credentials - const toAddress = new Address(output.address.toBytes()); - const addressMap = new FlareUtils.AddressMap([[toAddress, 0]]); + // Create AddressMaps based on signature slot order (matching credential order), not sorted addresses + // This matches the approach used in credentials: addressesIndex determines signature order + // AddressMaps should map addresses to signature slots in the same order as credentials + // If _fromAddresses is available, create AddressMap based on UTXO order (matching credential order) + // Otherwise, fall back to mapping just the output address + 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 + ) { + // Use centralized method for AddressMap creation + addressMap = this.createAddressMapForUtxo(firstUtxo, this.transaction._threshold); + } else { + // Fallback: map output address to slot 0 (for C-chain imports, output is the destination) + // Or map addresses sequentially if _fromAddresses is available but UTXO addresses are not + 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) => { + addressMap.set(new Address(addr), i); + }); + } else { + // Last resort: map output address + const toAddress = new Address(output.address.toBytes()); + addressMap.set(toAddress, 0); + } + } + const addressMaps = new FlareUtils.AddressMaps([addressMap]); // When credentials were extracted, use them directly to preserve existing signatures @@ -159,11 +187,15 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { [output] ); - // Create unsigned transaction with all potential signers in address map - const addressMap = new FlareUtils.AddressMap(); - this.transaction._fromAddresses.forEach((addr, i) => { - addressMap.set(new Address(addr), i); - }); + // Create AddressMaps based on signature slot order (matching credential order), not sorted addresses + // This matches the approach used in credentials: addressesIndex determines signature order + // AddressMaps should map addresses to signature slots in the same order as credentials + // For C-chain imports, we typically have one input, so use the first UTXO + // Use centralized method for AddressMap creation + const firstUtxo = this.transaction._utxos[0]; + const addressMap = firstUtxo + ? this.createAddressMapForUtxo(firstUtxo, this.transaction._threshold) + : new FlareUtils.AddressMap(); const addressMaps = new FlareUtils.AddressMaps([addressMap]); const unsignedTx = new UnsignedTx( @@ -230,41 +262,8 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { // Create credential with empty signatures for slot identification // Match avaxp behavior: dynamic ordering based on addressesIndex from UTXO - const hasAddresses = - this.transaction._fromAddresses && this.transaction._fromAddresses.length >= this.transaction._threshold; - - if (!hasAddresses) { - // If addresses not available, use all zeros - const emptySignatures = sigIndices.map(() => utils.createNewSig('')); - credentials.push(new Credential(emptySignatures)); - } else { - // Compute addressesIndex: position of each _fromAddresses in UTXO's address list - const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); - const addressesIndex = this.transaction._fromAddresses.map((a) => - utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0) - ); - - // either user (0) or recovery (2) - const firstIndex = this.recoverSigner ? 2 : 0; - const bitgoIndex = 1; - - // Dynamic ordering based on addressesIndex - let emptySignatures: ReturnType[]; - if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) { - // Bitgo comes first in signature order: [zeros, userAddress] - emptySignatures = [ - utils.createNewSig(''), - utils.createEmptySigWithAddress(Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex')), - ]; - } else { - // User comes first in signature order: [userAddress, zeros] - emptySignatures = [ - utils.createEmptySigWithAddress(Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex')), - utils.createNewSig(''), - ]; - } - credentials.push(new Credential(emptySignatures)); - } + // Use centralized method for credential creation + credentials.push(this.createCredentialForUtxo(utxo, this.transaction._threshold)); }); return { diff --git a/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts index 2df88356c9..73e102facc 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts @@ -92,55 +92,19 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder { } // Create proper UnsignedTx wrapper with credentials - const sortedAddresses = [...this.transaction._fromAddresses].sort((a, b) => Buffer.compare(a, b)); - const addressMaps = sortedAddresses.map((a, i) => new FlareUtils.AddressMap([[new Address(a), i]])); - - // When credentials were extracted, use them directly to preserve existing signatures // Match avaxp behavior: dynamic ordering based on addressesIndex from UTXO + // Use centralized methods for credential and AddressMap creation const txCredentials = credentials.length > 0 ? credentials - : this.transaction._utxos.map((utxo) => { - // either user (0) or recovery (2) - const firstIndex = this.recoverSigner ? 2 : 0; - const bitgoIndex = 1; - - // If UTXO has addresses, compute dynamic ordering - if (utxo && utxo.addresses && utxo.addresses.length > 0) { - const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); - const addressesIndex = this.transaction._fromAddresses.map((a) => - utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0) - ); - - // Dynamic ordering based on addressesIndex - let sigSlots: ReturnType[]; - if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) { - // Bitgo comes first: [zeros, userAddress] - sigSlots = [ - utils.createNewSig(''), - utils.createEmptySigWithAddress( - Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex') - ), - ]; - } else { - // User comes first: [userAddress, zeros] - sigSlots = [ - utils.createEmptySigWithAddress( - Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex') - ), - utils.createNewSig(''), - ]; - } - return new Credential(sigSlots); - } else { - // Fallback: use all zeros if no UTXO addresses available - const sigSlots: ReturnType[] = []; - for (let i = 0; i < this.transaction._threshold; i++) { - sigSlots.push(utils.createNewSig('')); - } - return new Credential(sigSlots); - } - }); + : this.transaction._utxos.map((utxo) => this.createCredentialForUtxo(utxo, this.transaction._threshold)); + + // Create AddressMaps based on signature slot order (matching credential order), not sorted addresses + // This matches the approach used in credentials: addressesIndex determines signature order + // AddressMaps should map addresses to signature slots in the same order as credentials + const addressMaps = this.transaction._utxos.map((utxo) => + this.createAddressMapForUtxo(utxo, this.transaction._threshold) + ); const unsignedTx = new UnsignedTx(importTx, [], new FlareUtils.AddressMaps(addressMaps), txCredentials); @@ -200,8 +164,13 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder { inputs // importedInputs (ins) ); - // Create address maps for signing - const addressMaps = this.transaction._fromAddresses.map((a, i) => new FlareUtils.AddressMap([[new Address(a), i]])); + // Create AddressMaps based on signature slot order (matching credential order), not sorted addresses + // This matches the approach used in credentials: addressesIndex determines signature order + // AddressMaps should map addresses to signature slots in the same order as credentials + // Use centralized method for AddressMap creation + const addressMaps = credentials.map((credential, credIdx) => + this.createAddressMapForUtxo(this.transaction._utxos[credIdx], this.transaction._threshold) + ); // Create unsigned transaction const unsignedTx = new UnsignedTx( @@ -264,41 +233,8 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder { // Create credential with empty signatures for slot identification // Match avaxp behavior: dynamic ordering based on addressesIndex from UTXO - const hasAddresses = - this.transaction._fromAddresses && this.transaction._fromAddresses.length >= this.transaction._threshold; - - if (!hasAddresses) { - // If addresses not available, use all zeros - const emptySignatures = sigIndices.map(() => utils.createNewSig('')); - credentials.push(new Credential(emptySignatures)); - } else { - // Compute addressesIndex: position of each _fromAddresses in UTXO's address list - const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); - const addressesIndex = this.transaction._fromAddresses.map((a) => - utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0) - ); - - // either user (0) or recovery (2) - const firstIndex = this.recoverSigner ? 2 : 0; - const bitgoIndex = 1; - - // Dynamic ordering based on addressesIndex - let emptySignatures: ReturnType[]; - if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) { - // Bitgo comes first in signature order: [zeros, userAddress] - emptySignatures = [ - utils.createNewSig(''), - utils.createEmptySigWithAddress(Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex')), - ]; - } else { - // User comes first in signature order: [userAddress, zeros] - emptySignatures = [ - utils.createEmptySigWithAddress(Buffer.from(this.transaction._fromAddresses[firstIndex]).toString('hex')), - utils.createNewSig(''), - ]; - } - credentials.push(new Credential(emptySignatures)); - } + // Use centralized method for credential creation + credentials.push(this.createCredentialForUtxo(utxo, this.transaction._threshold)); }); return { diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index db25a053d0..5c35637729 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -2,7 +2,15 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { TransactionType } from '@bitgo/sdk-core'; import { TransactionBuilder } from './transactionBuilder'; import { Transaction } from './transaction'; -import { TransferableInput, Int, Id, TypeSymbols, Credential } from '@flarenetwork/flarejs'; +import { + TransferableInput, + Int, + Id, + TypeSymbols, + Credential, + Address, + utils as FlareUtils, +} from '@flarenetwork/flarejs'; import { DecodedUtxoObj } from './iface'; import utils from './utils'; @@ -248,4 +256,98 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder { (this.transaction as Transaction)._fee.fee = fee; return this; } + + /** + * Create credential with dynamic ordering based on addressesIndex from UTXO + * Matches avaxp behavior: signature order depends on UTXO address positions + * @param utxo - The UTXO to create credential for + * @param threshold - Number of signatures required + * @returns Credential with empty signatures ordered based on UTXO positions + * @protected + */ + protected createCredentialForUtxo(utxo: DecodedUtxoObj, threshold: number): Credential { + const sender = (this.transaction as Transaction)._fromAddresses; + const hasAddresses = sender && sender.length >= threshold; + + if (!hasAddresses || !utxo.addresses || utxo.addresses.length === 0) { + // Fallback: use all zeros if no addresses available + const emptySignatures: ReturnType[] = []; + for (let i = 0; i < threshold; i++) { + emptySignatures.push(utils.createNewSig('')); + } + return new Credential(emptySignatures); + } + + // Compute addressesIndex: position of each _fromAddresses in UTXO's address list + const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); + const addressesIndex = sender.map((a) => + utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0) + ); + + // either user (0) or recovery (2) + const firstIndex = this.recoverSigner ? 2 : 0; + const bitgoIndex = 1; + + // Dynamic ordering based on addressesIndex + let emptySignatures: ReturnType[]; + if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) { + // Bitgo comes first in signature order: [zeros, userAddress] + emptySignatures = [ + utils.createNewSig(''), + utils.createEmptySigWithAddress(Buffer.from(sender[firstIndex]).toString('hex')), + ]; + } else { + // User comes first in signature order: [userAddress, zeros] + emptySignatures = [ + utils.createEmptySigWithAddress(Buffer.from(sender[firstIndex]).toString('hex')), + utils.createNewSig(''), + ]; + } + return new Credential(emptySignatures); + } + + /** + * Create AddressMap based on signature slot order (matching credential order), not sorted addresses + * This matches the approach used in credentials: addressesIndex determines signature order + * AddressMaps should map addresses to signature slots in the same order as credentials + * @param utxo - The UTXO to create AddressMap for + * @param threshold - Number of signatures required + * @returns AddressMap that maps addresses to signature slots based on UTXO order + * @protected + */ + protected createAddressMapForUtxo(utxo: DecodedUtxoObj, threshold: number): FlareUtils.AddressMap { + const addressMap = new FlareUtils.AddressMap(); + const sender = (this.transaction as Transaction)._fromAddresses; + + // If UTXO has addresses, compute addressesIndex to determine signature order + if (utxo && utxo.addresses && utxo.addresses.length > 0 && sender && sender.length >= threshold) { + const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a)); + const addressesIndex = sender.map((a) => + utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0) + ); + + const firstIndex = this.recoverSigner ? 2 : 0; + const bitgoIndex = 1; + + // Determine signature slot order based on addressesIndex (same logic as credentials) + if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) { + // Bitgo comes first: slot 0 = bitgo, slot 1 = firstIndex + addressMap.set(new Address(sender[bitgoIndex]), 0); + addressMap.set(new Address(sender[firstIndex]), 1); + } else { + // User/recovery comes first: slot 0 = firstIndex, slot 1 = bitgo + addressMap.set(new Address(sender[firstIndex]), 0); + addressMap.set(new Address(sender[bitgoIndex]), 1); + } + } else { + // Fallback: map addresses sequentially if no UTXO addresses available + if (sender && sender.length >= threshold) { + sender.slice(0, threshold).forEach((addr, i) => { + addressMap.set(new Address(addr), i); + }); + } + } + + return addressMap; + } } diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index d461bd5b91..4b66cb5a43 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -200,5 +200,190 @@ describe('Flrp Export In P Tx Builder', () => { rawTx.should.equal(signedExportHex); tx.id.should.equal('ka8at5CinmpUc6QMVr33dyUJi156LKMdodrJM59kS6EWr3vHg'); }); + + it('should FAIL with unsorted UTXO addresses - demonstrates AddressMap mismatch issue for export in P-chain tx', async () => { + // This test uses UTXO addresses in UNSORTED order to demonstrate the issue. + // With unsorted addresses, the current implementation will create AddressMaps incorrectly + // because it uses sorted addresses, not UTXO address order. + // + // Expected: AddressMap should map addresses to signature slots based on UTXO order (sigIndicies) + // Current (WRONG): AddressMap uses sorted addresses with sequential slots + // + // This test WILL FAIL with current implementation because AddressMaps don't match sigIndicies + + // UTXO addresses in UNSORTED order (different from sorted) + // Sorted would be: [0x12cb... (smallest), 0xa6e0... (middle), 0xc386... (largest)] + // Unsorted: [0xc386... (largest), 0x12cb... (smallest), 0xa6e0... (middle)] + const unsortedUtxoAddresses = [ + '0xc386d58d09a9ae77cf1cf07bf1c9de44ebb0c9f3', // Largest (would be index 2 if sorted) + '0x12cb32eaf92553064db98d271b56cba079ec78f5', // Smallest (would be index 0 if sorted) + '0xa6e0c1abd0132f70efb77e2274637ff336a29a57', // Middle (would be index 1 if sorted) + ]; + + // Corresponding P-chain addresses (in same order as UTXO) + const pAddresses = [ + 'P-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8', // Maps to 0xc386... (UTXO index 0) + 'P-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd', // Maps to 0x12cb... (UTXO index 1) + 'P-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh', // Maps to 0xa6e0... (UTXO index 2) + ]; + + // Create UTXO with UNSORTED addresses + // Amount must cover export amount + fee + const exportAmount = '50000000'; + const fee = '1261000'; + const utxoAmount = (BigInt(exportAmount) + BigInt(fee)).toString(); // amount + fee + + const utxo: DecodedUtxoObj = { + outputID: 0, + amount: utxoAmount, + txid: 'zstyYq5riDKYDSR3fUYKKkuXKJ1aJCe8WNrXKqEBJD4CGwzFw', + outputidx: '0', + addresses: unsortedUtxoAddresses, // UNSORTED order + threshold: 2, + }; + + // Build transaction + const txBuilder = factory + .getExportInPBuilder() + .threshold(2) + .locktime(0) + .fromPubKey(pAddresses) + .externalChainId(testData.sourceChainId) + .amount(exportAmount) + .fee(fee) + .utxos([utxo]); + + // Build unsigned transaction + const unsignedTx = await txBuilder.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + // Parse it back to inspect AddressMaps and sigIndicies + const parsedBuilder = factory.from(unsignedHex); + const parsedTx = await parsedBuilder.build(); + const flareTx = (parsedTx as any)._flareTransaction; + + // Get the input to check sigIndicies + const exportTx = flareTx.tx as any; + const input = exportTx.baseTx.inputs[0]; + const transferInput = input.input; + const sigIndicies = transferInput.sigIndicies(); + + // sigIndicies tells us: sigIndicies[slotIndex] = utxoAddressIndex + // For threshold=2, we need signatures for first 2 addresses in UTXO order + // UTXO order: [0xc386... (index 0), 0x12cb... (index 1), 0xa6e0... (index 2)] + // So sigIndicies should be [0, 1] meaning: slot 0 = UTXO index 0, slot 1 = UTXO index 1 + + // Verify sigIndicies are [0, 1] (first 2 addresses in UTXO order, NOT sorted order) + sigIndicies.length.should.equal(2); + sigIndicies[0].should.equal(0, 'First signature slot should be UTXO address index 0 (0xc386...)'); + sigIndicies[1].should.equal(1, 'Second signature slot should be UTXO address index 1 (0x12cb...)'); + + // The critical test: Verify that signature slots have embedded addresses based on UTXO order + // With unsorted UTXO addresses, this will FAIL if AddressMaps don't match UTXO order + // + // sigIndicies tells us: sigIndicies[slotIndex] = utxoAddressIndex + // For threshold=2, we need signatures for first 2 addresses in UTXO order + // UTXO order: [0xc386... (index 0), 0x12cb... (index 1), 0xa6e0... (index 2)] + // So sigIndicies should be [0, 1] meaning: slot 0 = UTXO index 0, slot 1 = UTXO index 1 + + // Parse the credential to see which slots have which embedded addresses + const credential = flareTx.credentials[0]; + const signatures = credential.getSignatures(); + + // Helper function to check if signature has embedded address (same logic as transaction.ts) + const testUtils2 = require('../../../src/lib/utils').default; + const isEmptySignature = (signature: string): boolean => { + return !!signature && testUtils2.removeHexPrefix(signature).startsWith('0'.repeat(90)); + }; + + const hasEmbeddedAddress = (signature: string): boolean => { + if (!isEmptySignature(signature)) return false; + const cleanSig = testUtils2.removeHexPrefix(signature); + if (cleanSig.length < 130) return false; + const embeddedPart = cleanSig.substring(90, 130); + // Check if embedded part is not all zeros + return embeddedPart !== '0'.repeat(40); + }; + + // Extract embedded addresses from signature slots + const embeddedAddresses: string[] = []; + + signatures.forEach((sig: string, slotIndex: number) => { + if (hasEmbeddedAddress(sig)) { + // Extract embedded address (after position 90, 40 chars = 20 bytes) + const cleanSig = testUtils2.removeHexPrefix(sig); + const embeddedAddr = cleanSig.substring(90, 130).toLowerCase(); + embeddedAddresses[slotIndex] = '0x' + embeddedAddr; + } + }); + + // Verify: Credentials only embed ONE address (user/recovery), not both + // The embedded address should be based on addressesIndex logic, not sorted order + // + // Compute addressesIndex to determine expected signature order + const utxoAddressBytes = unsortedUtxoAddresses.map((addr) => testUtils2.parseAddress(addr)); + const pAddressBytes = pAddresses.map((addr) => testUtils2.parseAddress(addr)); + + const addressesIndex: number[] = []; + pAddressBytes.forEach((pAddr) => { + const utxoIndex = utxoAddressBytes.findIndex( + (uAddr) => Buffer.compare(Buffer.from(uAddr), Buffer.from(pAddr)) === 0 + ); + addressesIndex.push(utxoIndex); + }); + + // firstIndex = 0 (user), bitgoIndex = 1 + const firstIndex = 0; + const bitgoIndex = 1; + + // Determine expected signature order based on addressesIndex + const userComesFirst = addressesIndex[bitgoIndex] > addressesIndex[firstIndex]; + + // Expected credential structure: + // - If user comes first: [userAddress, zeros] + // - If bitgo comes first: [zeros, userAddress] + const userAddressHex = Buffer.from(pAddressBytes[firstIndex]).toString('hex').toLowerCase(); + const expectedUserAddr = '0x' + userAddressHex; + + if (userComesFirst) { + // Expected: [userAddress, zeros] + // Slot 0 should have user address (pAddr0 = 0xc386... = UTXO index 0) + if (embeddedAddresses[0]) { + embeddedAddresses[0] + .toLowerCase() + .should.equal( + expectedUserAddr, + `Slot 0 should have user address (${expectedUserAddr}) because user comes first in UTXO order` + ); + } else { + throw new Error(`Slot 0 should have embedded user address, but is empty`); + } + // Slot 1 should be zeros (no embedded address) + if (embeddedAddresses[1]) { + throw new Error(`Slot 1 should be zeros, but has embedded address: ${embeddedAddresses[1]}`); + } + } else { + // Expected: [zeros, userAddress] + // Slot 0 should be zeros + if (embeddedAddresses[0]) { + throw new Error(`Slot 0 should be zeros, but has embedded address: ${embeddedAddresses[0]}`); + } + // Slot 1 should have user address + if (embeddedAddresses[1]) { + embeddedAddresses[1] + .toLowerCase() + .should.equal( + expectedUserAddr, + `Slot 1 should have user address (${expectedUserAddr}) because bitgo comes first in UTXO order` + ); + } else { + throw new Error(`Slot 1 should have embedded user address, but is empty`); + } + } + + // The key verification: AddressMaps should match the credential order + // With the fix, AddressMaps are created using the same addressesIndex logic as credentials + // This ensures signing works correctly even with unsorted UTXO addresses + }); }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts index 75076e86f8..6af56a1a2b 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts @@ -4,6 +4,8 @@ import { TransactionBuilderFactory, DecodedUtxoObj } from '../../../src/lib'; import { coins } from '@bitgo/statics'; import { IMPORT_IN_C as testData } from '../../resources/transactionData/importInC'; import signFlowTest from './signFlowTestSuit'; +import { UnsignedTx } from '@flarenetwork/flarejs'; +import testUtils from '../../../src/lib/utils'; describe('Flrp Import In C Tx Builder', () => { const factory = new TransactionBuilderFactory(coins.get('tflrp')); @@ -60,5 +62,249 @@ describe('Flrp Import In C Tx Builder', () => { rawTx.should.equal(signedImportHex); tx.id.should.equal('2ks9vW1SVWD4KsNPHgXnV5dpJaCcaxVNbQW4H7t9BMDxApGvfa'); }); + + it('should FAIL with unsorted UTXO addresses - demonstrates AddressMap mismatch issue for import in C-chain tx', async () => { + // This test uses UTXO addresses in UNSORTED order to demonstrate the issue. + // With unsorted addresses, the current implementation will create AddressMaps incorrectly + // because it uses sequential indices, not UTXO address order. + // + // Expected: AddressMap should map addresses to signature slots based on UTXO order (addressesIndex) + // Current (WRONG): AddressMap uses sequential indices (0, 1, 2...) + // + // This test WILL FAIL with current implementation because AddressMaps don't match credential order + + // UTXO addresses in UNSORTED order (different from sorted) + // Sorted would be: [0x3329... (smallest), 0x7e91... (middle), 0xc732... (largest)] + // Unsorted: [0xc732... (largest), 0x3329... (smallest), 0x7e91... (middle)] + const unsortedUtxoAddresses = [ + '0xc7324437c96c7c8a6a152da2385c1db5c3ab1f91', // Largest (would be index 2 if sorted) + '0x3329be7d01cd3ebaae6654d7327dd9f17a2e1581', // Smallest (would be index 0 if sorted) + '0x7e918a5e8083ae4c9f2f0ed77055c24bf3665001', // Middle (would be index 1 if sorted) + ]; + + // Corresponding P-chain addresses (in same order as _fromAddresses) + const pAddresses = [ + 'P-costwo1xv5mulgpe5lt4tnx2ntnylwe79azu9vpja6lut', // Maps to 0xc732... (UTXO index 0 in unsorted) + 'P-costwo106gc5h5qswhye8e0pmthq4wzf0ekv5qppsrvpu', // Maps to 0x3329... (UTXO index 1 in unsorted) + 'P-costwo1cueygd7fd37g56s49k3rshqakhp6k8u3adzt6m', // Maps to 0x7e91... (UTXO index 2 in unsorted) + ]; + + // Create UTXO with UNSORTED addresses + const amount = '500000000'; // 0.5 FLR + const fee = '5000000'; // Example fee + const utxoAmount = (BigInt(amount) + BigInt(fee) + BigInt('10000000')).toString(); // amount + fee + some buffer + + const utxo: DecodedUtxoObj = { + outputID: 0, + amount: utxoAmount, + txid: '2vPMx8P63adgBae7GAWFx7qvJDwRmMnDCyKddHRBXWhysjX4BP', + outputidx: '1', + addresses: unsortedUtxoAddresses, // UNSORTED order + threshold: 2, + }; + + // Build transaction + const txBuilder = factory + .getImportInCBuilder() + .threshold(2) + .fromPubKey(pAddresses) + .utxos([utxo]) + .to(testData.to) + .feeRate(testData.fee); + + // Build unsigned transaction + const unsignedTx = await txBuilder.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + // Get AddressMaps from the ORIGINAL transaction (before parsing) + // The parsed transaction's AddressMap only contains the output address, not _fromAddresses + const originalFlareTx = (unsignedTx as any)._flareTransaction; + const originalAddressMaps = (originalFlareTx as any as UnsignedTx).addressMaps; + + // Parse it back to inspect AddressMaps and credentials + const parsedBuilder = factory.from(unsignedHex); + const parsedTx = await parsedBuilder.build(); + const flareTx = (parsedTx as any)._flareTransaction; + + // Get the input to check sigIndicies (for C-chain imports, inputs are importedInputs) + const importTx = flareTx.tx as any; + const input = importTx.importedInputs[0]; + const sigIndicies = input.sigIndicies(); + + // sigIndicies tells us: sigIndicies[slotIndex] = utxoAddressIndex + // For threshold=2, we need signatures for first 2 addresses in UTXO order + // UTXO order: [0xc732... (index 0), 0x3329... (index 1), 0x7e91... (index 2)] + // So sigIndicies should be [0, 1] meaning: slot 0 = UTXO index 0, slot 1 = UTXO index 1 + + // Verify sigIndicies are [0, 1] (first 2 addresses in UTXO order, NOT sorted order) + sigIndicies.length.should.equal(2); + sigIndicies[0].should.equal(0, 'First signature slot should be UTXO address index 0 (0xc732...)'); + sigIndicies[1].should.equal(1, 'Second signature slot should be UTXO address index 1 (0x3329...)'); + + // The critical test: Verify that signature slots have embedded addresses based on UTXO order + // With unsorted UTXO addresses, this will FAIL if AddressMaps don't match UTXO order + // + // Parse the credential to see which slots have which embedded addresses + const credential = flareTx.credentials[0]; + const signatures = credential.getSignatures(); + + // Extract embedded addresses from signature slots + const embeddedAddresses: string[] = []; + const isEmptySignature = (signature: string): boolean => { + return !!signature && testUtils.removeHexPrefix(signature).startsWith('0'.repeat(90)); + }; + + const hasEmbeddedAddress = (signature: string): boolean => { + if (!isEmptySignature(signature)) return false; + const cleanSig = testUtils.removeHexPrefix(signature); + if (cleanSig.length < 130) return false; + const embeddedPart = cleanSig.substring(90, 130); + // Check if embedded part is not all zeros + return embeddedPart !== '0'.repeat(40); + }; + + signatures.forEach((sig: string, slotIndex: number) => { + if (hasEmbeddedAddress(sig)) { + // Extract embedded address (after position 90, 40 chars = 20 bytes) + const cleanSig = testUtils.removeHexPrefix(sig); + const embeddedAddr = cleanSig.substring(90, 130).toLowerCase(); + embeddedAddresses[slotIndex] = '0x' + embeddedAddr; + } + }); + + // Verify: Credentials only embed ONE address (user/recovery), not both + // The embedded address should be based on addressesIndex logic, not sequential order + // + // Compute addressesIndex to determine expected signature order + const utxoAddressBytes = unsortedUtxoAddresses.map((addr) => testUtils.parseAddress(addr)); + const pAddressBytes = pAddresses.map((addr) => testUtils.parseAddress(addr)); + + const addressesIndex: number[] = []; + pAddressBytes.forEach((pAddr) => { + const utxoIndex = utxoAddressBytes.findIndex( + (uAddr) => Buffer.compare(Buffer.from(uAddr), Buffer.from(pAddr)) === 0 + ); + addressesIndex.push(utxoIndex); + }); + + // firstIndex = 0 (user), bitgoIndex = 1 + const firstIndex = 0; + const bitgoIndex = 1; + + // Determine expected signature order based on addressesIndex + const userComesFirst = addressesIndex[bitgoIndex] > addressesIndex[firstIndex]; + + // Expected credential structure: + // - If user comes first: [userAddress, zeros] + // - If bitgo comes first: [zeros, userAddress] + const userAddressHex = Buffer.from(pAddressBytes[firstIndex]).toString('hex').toLowerCase(); + const expectedUserAddr = '0x' + userAddressHex; + + if (userComesFirst) { + // Expected: [userAddress, zeros] + // Slot 0 should have user address (pAddr0 = 0xc732... = UTXO index 0) + if (embeddedAddresses[0]) { + embeddedAddresses[0] + .toLowerCase() + .should.equal( + expectedUserAddr, + `Slot 0 should have user address (${expectedUserAddr}) because user comes first in UTXO order` + ); + } else { + throw new Error(`Slot 0 should have embedded user address, but is empty`); + } + // Slot 1 should be zeros (no embedded address) + if (embeddedAddresses[1]) { + throw new Error(`Slot 1 should be zeros, but has embedded address: ${embeddedAddresses[1]}`); + } + } else { + // Expected: [zeros, userAddress] + // Slot 0 should be zeros + if (embeddedAddresses[0]) { + throw new Error(`Slot 0 should be zeros, but has embedded address: ${embeddedAddresses[0]}`); + } + // Slot 1 should have user address + if (embeddedAddresses[1]) { + embeddedAddresses[1] + .toLowerCase() + .should.equal( + expectedUserAddr, + `Slot 1 should have user address (${expectedUserAddr}) because bitgo comes first in UTXO order` + ); + } else { + throw new Error(`Slot 1 should have embedded user address, but is empty`); + } + } + + // The key verification: AddressMaps should match the credential order + // Current implementation (WRONG): AddressMaps use sequential indices (0, 1, 2...) + // Expected (CORRECT): AddressMaps should use addressesIndex logic, matching credential order + // + // Get AddressMaps from the ORIGINAL transaction (not parsed, because parsed AddressMap only has output address) + // For C-chain imports, originalFlareTx is EVMUnsignedTx which has addressMaps property + + const addressMaps = originalAddressMaps; + addressMaps.toArray().length.should.equal(1, 'Should have one AddressMap for one input'); + + const addressMap = addressMaps.toArray()[0]; + + // Expected: Based on addressesIndex logic + // If user comes first: slot 0 = user, slot 1 = bitgo + // If bitgo comes first: slot 0 = bitgo, slot 1 = user + const expectedSlot0Addr = userComesFirst ? pAddressBytes[firstIndex] : pAddressBytes[bitgoIndex]; + const expectedSlot1Addr = userComesFirst ? pAddressBytes[bitgoIndex] : pAddressBytes[firstIndex]; + + // AddressMap maps: Address -> slot index + // We need to check which addresses are mapped to slots 0 and 1 + // AddressMap.get() returns the slot index for a given address + + // Verify that AddressMap correctly maps addresses based on credential order (UTXO order) + // The AddressMap should map the addresses that appear in credentials to the correct slots + const { Address } = require('@flarenetwork/flarejs'); + const expectedSlot0Address = new Address(expectedSlot0Addr); + const expectedSlot1Address = new Address(expectedSlot1Addr); + const expectedSlot0FromMap = addressMap.get(expectedSlot0Address); + const expectedSlot1FromMap = addressMap.get(expectedSlot1Address); + + // Verify that the expected addresses map to the correct slots + if (expectedSlot0FromMap === undefined) { + throw new Error(`Address at UTXO index ${addressesIndex[firstIndex]} not found in AddressMap`); + } + if (expectedSlot1FromMap === undefined) { + throw new Error(`Address at UTXO index ${addressesIndex[bitgoIndex]} not found in AddressMap`); + } + expectedSlot0FromMap.should.equal(0, `Address at UTXO index ${addressesIndex[firstIndex]} should map to slot 0`); + expectedSlot1FromMap.should.equal(1, `Address at UTXO index ${addressesIndex[bitgoIndex]} should map to slot 1`); + + // If addressesIndex is not sequential ([0, 1, ...]), verify that sequential mapping is NOT used incorrectly + // Sequential mapping means: pAddresses[0] -> slot 0, pAddresses[1] -> slot 1, regardless of UTXO order + const usesSequentialMapping = addressesIndex[0] === 0 && addressesIndex[1] === 1; + + if (!usesSequentialMapping) { + // Check if AddressMap uses sequential mapping (array order) instead of UTXO order + const sequentialSlot0 = addressMap.get(new Address(pAddressBytes[0])); + const sequentialSlot1 = addressMap.get(new Address(pAddressBytes[1])); + + // Sequential mapping would map pAddresses[0] -> slot 0, pAddresses[1] -> slot 1 + // But we want UTXO order mapping based on addressesIndex + const isSequential = sequentialSlot0 === 0 && sequentialSlot1 === 1; + + // Check if pAddresses[0] and pAddresses[1] are the expected addresses for slots 0 and 1 + // If they are, then sequential mapping happens to be correct (by coincidence) + const pAddress0IsExpectedSlot0 = + Buffer.compare(Buffer.from(pAddressBytes[0]), Buffer.from(expectedSlot0Addr)) === 0; + const pAddress1IsExpectedSlot1 = + Buffer.compare(Buffer.from(pAddressBytes[1]), Buffer.from(expectedSlot1Addr)) === 0; + + // If sequential mapping is used but it's NOT correct (doesn't match expected addresses), fail + if (isSequential && (!pAddress0IsExpectedSlot0 || !pAddress1IsExpectedSlot1)) { + throw new Error( + `AddressMap uses sequential mapping (array order) but should use UTXO order. ` + + `addressesIndex: [${addressesIndex.join(', ')}]. ` + + `Expected slot 0 = address at UTXO index ${addressesIndex[firstIndex]}, slot 1 = address at UTXO index ${addressesIndex[bitgoIndex]}` + ); + } + } + }); }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts index 2e17a19118..117e5c7c73 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts @@ -4,6 +4,7 @@ import { IMPORT_IN_P as testData } from '../../resources/transactionData/importI import { TransactionBuilderFactory, DecodedUtxoObj, Transaction } from '../../../src/lib'; import { coins, FlareNetwork } from '@bitgo/statics'; import signFlowTest from './signFlowTestSuit'; +import testUtils from '../../../src/lib/utils'; describe('Flrp Import In P Tx Builder', () => { const coinConfig = coins.get('tflrp'); @@ -140,5 +141,227 @@ describe('Flrp Import In P Tx Builder', () => { rawTx.should.equal(signedImportHex); tx.id.should.equal('2vwvuXp47dsUmqb4vkaMk7UsukrZNapKXT2ruZhVibbjMDpqr9'); }); + + it('should FAIL with unsorted UTXO addresses - demonstrates AddressMap mismatch issue', async () => { + // This test uses UTXO addresses in UNSORTED order to demonstrate the issue. + // With unsorted addresses, the current implementation will create AddressMaps incorrectly + // because it uses sorted addresses, not UTXO address order. + // + // Expected: AddressMap should map addresses to signature slots based on UTXO order (sigIndicies) + // Current (WRONG): AddressMap uses sorted addresses with sequential slots + // + // This test WILL FAIL with current implementation because AddressMaps don't match sigIndicies + + // UTXO addresses in UNSORTED order (different from sorted) + // Sorted would be: [0x12cb... (smallest), 0xa6e0... (middle), 0xc386... (largest)] + // Unsorted: [0xc386... (largest), 0x12cb... (smallest), 0xa6e0... (middle)] + const unsortedUtxoAddresses = [ + '0xc386d58d09a9ae77cf1cf07bf1c9de44ebb0c9f3', // Largest (would be index 2 if sorted) + '0x12cb32eaf92553064db98d271b56cba079ec78f5', // Smallest (would be index 0 if sorted) + '0xa6e0c1abd0132f70efb77e2274637ff336a29a57', // Middle (would be index 1 if sorted) + ]; + + // Corresponding P-chain addresses (in same order as UTXO) + const pAddresses = [ + 'P-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8', // Maps to 0xc386... (UTXO index 0) + 'P-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd', // Maps to 0x12cb... (UTXO index 1) + 'P-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh', // Maps to 0xa6e0... (UTXO index 2) + ]; + + // Create UTXO with UNSORTED addresses + const utxo: DecodedUtxoObj = { + outputID: 0, + amount: '50000000', + txid: 'zstyYq5riDKYDSR3fUYKKkuXKJ1aJCe8WNrXKqEBJD4CGwzFw', + outputidx: '0', + addresses: unsortedUtxoAddresses, // UNSORTED order + threshold: 2, + }; + + // Build transaction + const txBuilder = factory + .getImportInPBuilder() + .threshold(2) + .locktime(0) + .fromPubKey(pAddresses) + .externalChainId(testData.sourceChainId) + .fee('1261000') + .utxos([utxo]); + + // Build unsigned transaction + const unsignedTx = await txBuilder.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + // Parse it back to inspect AddressMaps and sigIndicies + const parsedBuilder = factory.from(unsignedHex); + const parsedTx = await parsedBuilder.build(); + const flareTx = (parsedTx as any)._flareTransaction; + + // Get the input to check sigIndicies + const importTx = flareTx.tx as any; + const input = importTx.ins[0]; + const transferInput = input.input; + const sigIndicies = transferInput.sigIndicies(); + + // sigIndicies tells us: sigIndicies[slotIndex] = utxoAddressIndex + // For threshold=2, we need signatures for first 2 addresses in UTXO order + // UTXO order: [0xc386... (index 0), 0x12cb... (index 1), 0xa6e0... (index 2)] + // So sigIndicies should be [0, 1] meaning: slot 0 = UTXO index 0, slot 1 = UTXO index 1 + + // Verify sigIndicies are [0, 1] (first 2 addresses in UTXO order, NOT sorted order) + sigIndicies.length.should.equal(2); + sigIndicies[0].should.equal(0, 'First signature slot should be UTXO address index 0 (0xc386...)'); + sigIndicies[1].should.equal(1, 'Second signature slot should be UTXO address index 1 (0x12cb...)'); + + // Now the key test: AddressMap should map addresses based on sigIndicies (UTXO order) + // NOT based on sorted order + // + // Current implementation (WRONG): + // - Sorts addresses: [0x12cb... (smallest), 0xa6e0... (middle), 0xc386... (largest)] + // - Maps: sorted[0] -> slot 0, sorted[1] -> slot 1 + // - This means: 0x12cb... -> slot 0, 0xa6e0... -> slot 1 (WRONG!) + // + // Expected (CORRECT): + // - Uses UTXO order via sigIndicies: sigIndicies[0]=0, sigIndicies[1]=1 + // - Maps: address at UTXO index 0 (0xc386...) -> slot 0, address at UTXO index 1 (0x12cb...) -> slot 1 + // - This means: 0xc386... -> slot 0, 0x12cb... -> slot 1 (CORRECT!) + + // Parse addresses + // Address at UTXO index 0 (0xc386...) should map to signature slot 0 + const pAddr0Bytes = testUtils.parseAddress(pAddresses[0]); // Corresponds to UTXO index 0 + + // Address at UTXO index 1 (0x12cb...) should map to signature slot 1 + const pAddr1Bytes = testUtils.parseAddress(pAddresses[1]); // Corresponds to UTXO index 1 + + // Get addresses from AddressMap + const addressesInMap = flareTx.getAddresses(); + + // Verify addresses are in the map + const addr0InMap = addressesInMap.some((addr) => Buffer.compare(Buffer.from(addr), pAddr0Bytes) === 0); + const addr1InMap = addressesInMap.some((addr) => Buffer.compare(Buffer.from(addr), pAddr1Bytes) === 0); + + addr0InMap.should.be.true('Address at UTXO index 0 should be in AddressMap'); + addr1InMap.should.be.true('Address at UTXO index 1 should be in AddressMap'); + + // The critical assertion: AddressMap should map addresses to signature slots based on sigIndicies + // Since we can't directly access individual AddressMap instances, we verify the behavior + // by checking that the transaction structure is correct. + // + // With current implementation (WRONG): + // - AddressMap maps sorted addresses: 0x12cb... -> slot 0, 0xa6e0... -> slot 1 + // - But sigIndicies say: slot 0 = UTXO index 0 (0xc386...), slot 1 = UTXO index 1 (0x12cb...) + // - Mismatch! AddressMap says 0x12cb... -> slot 0, but sigIndicies say slot 0 = 0xc386... + // + // This mismatch will cause signing to fail because: + // - Signing logic uses AddressMap to find which slot to sign + // - But credentials expect signatures in slots based on sigIndicies (UTXO order) + // - Result: "wrong signature" error on-chain + + // The critical test: Verify that signature slots have embedded addresses based on UTXO order + // With unsorted UTXO addresses, this will FAIL if AddressMaps don't match UTXO order + // + // sigIndicies tells us: sigIndicies[slotIndex] = utxoAddressIndex + // For threshold=2, we need signatures for first 2 addresses in UTXO order + // UTXO order: [0xc386... (index 0), 0x12cb... (index 1), 0xa6e0... (index 2)] + // So sigIndicies should be [0, 1] meaning: slot 0 = UTXO index 0, slot 1 = UTXO index 1 + + // Parse the credential to see which slots have which embedded addresses + const credential = flareTx.credentials[0]; + const signatures = credential.getSignatures(); + + // Extract embedded addresses from signature slots + const embeddedAddresses: string[] = []; + + // Helper function to check if signature has embedded address (same logic as transaction.ts) + const isEmptySignature = (signature: string): boolean => { + return !!signature && testUtils.removeHexPrefix(signature).startsWith('0'.repeat(90)); + }; + + const hasEmbeddedAddress = (signature: string): boolean => { + if (!isEmptySignature(signature)) return false; + const cleanSig = testUtils.removeHexPrefix(signature); + if (cleanSig.length < 130) return false; + const embeddedPart = cleanSig.substring(90, 130); + // Check if embedded part is not all zeros + return embeddedPart !== '0'.repeat(40); + }; + + signatures.forEach((sig: string, slotIndex: number) => { + if (hasEmbeddedAddress(sig)) { + // Extract embedded address (after position 90, 40 chars = 20 bytes) + const cleanSig = testUtils.removeHexPrefix(sig); + const embeddedAddr = cleanSig.substring(90, 130).toLowerCase(); + embeddedAddresses[slotIndex] = '0x' + embeddedAddr; + } + }); + + // Verify: Credentials only embed ONE address (user/recovery), not both + // The embedded address should be based on addressesIndex logic, not sorted order + // + // Compute addressesIndex to determine expected signature order + const utxoAddressBytes = unsortedUtxoAddresses.map((addr) => testUtils.parseAddress(addr)); + const pAddressBytes = pAddresses.map((addr) => testUtils.parseAddress(addr)); + + const addressesIndex: number[] = []; + pAddressBytes.forEach((pAddr) => { + const utxoIndex = utxoAddressBytes.findIndex( + (uAddr) => Buffer.compare(Buffer.from(uAddr), Buffer.from(pAddr)) === 0 + ); + addressesIndex.push(utxoIndex); + }); + + // firstIndex = 0 (user), bitgoIndex = 1 + const firstIndex = 0; + const bitgoIndex = 1; + + // Determine expected signature order based on addressesIndex + const userComesFirst = addressesIndex[bitgoIndex] > addressesIndex[firstIndex]; + + // Expected credential structure: + // - If user comes first: [userAddress, zeros] + // - If bitgo comes first: [zeros, userAddress] + const userAddressHex = Buffer.from(pAddressBytes[firstIndex]).toString('hex').toLowerCase(); + const expectedUserAddr = '0x' + userAddressHex; + + if (userComesFirst) { + // Expected: [userAddress, zeros] + // Slot 0 should have user address (pAddr0 = 0xc386... = UTXO index 0) + if (embeddedAddresses[0]) { + embeddedAddresses[0] + .toLowerCase() + .should.equal( + expectedUserAddr, + `Slot 0 should have user address (${expectedUserAddr}) because user comes first in UTXO order` + ); + } else { + throw new Error(`Slot 0 should have embedded user address, but is empty`); + } + // Slot 1 should be zeros (no embedded address) + if (embeddedAddresses[1]) { + throw new Error(`Slot 1 should be zeros, but has embedded address: ${embeddedAddresses[1]}`); + } + } else { + // Expected: [zeros, userAddress] + // Slot 0 should be zeros + if (embeddedAddresses[0]) { + throw new Error(`Slot 0 should be zeros, but has embedded address: ${embeddedAddresses[0]}`); + } + // Slot 1 should have user address + if (embeddedAddresses[1]) { + embeddedAddresses[1] + .toLowerCase() + .should.equal( + expectedUserAddr, + `Slot 1 should have user address (${expectedUserAddr}) because bitgo comes first in UTXO order` + ); + } else { + throw new Error(`Slot 1 should have embedded user address, but is empty`); + } + } + + // The key verification: AddressMaps should match the credential order + // With the fix, AddressMaps are created using the same addressesIndex logic as credentials + // This ensures signing works correctly even with unsorted UTXO addresses + }); }); });