Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
});
}
}
14 changes: 0 additions & 14 deletions modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
});
}
}
23 changes: 0 additions & 23 deletions modules/sdk-coin-flrp/src/lib/ImportInPTxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
});
}
}
43 changes: 39 additions & 4 deletions modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -123,14 +128,44 @@ 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)
);
}
});
}

/**
* 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
Expand Down
40 changes: 38 additions & 2 deletions modules/sdk-coin-flrp/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
135 changes: 135 additions & 0 deletions modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading