Skip to content

Commit c4e94e7

Browse files
committed
fix(sdk-coin-flrp): ensure AddressMaps are created based on UTXO address order
TICKET: WIN-8279
1 parent a54becd commit c4e94e7

File tree

3 files changed

+251
-98
lines changed

3 files changed

+251
-98
lines changed

modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,6 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder {
110110
this.transaction._rawSignedBytes = rawBytes;
111111
}
112112

113-
// Create proper UnsignedTx wrapper with credentials
114-
const sortedAddresses = [...this.transaction._fromAddresses].sort((a, b) => Buffer.compare(a, b));
115-
116113
// When credentials were extracted, use them directly to preserve existing signatures
117114
// Otherwise, create empty credentials with dynamic ordering based on addressesIndex
118115
// Match avaxp behavior: order depends on UTXO address positions
@@ -167,13 +164,40 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder {
167164
}
168165
});
169166

170-
// Create address maps for signing - one per input/credential
171-
// Each address map contains all addresses mapped to their indices
172-
const addressMaps = txCredentials.map(() => {
167+
// Create AddressMaps based on signature slot order (matching credential order), not sorted addresses
168+
// This matches the approach used in credentials: addressesIndex determines signature order
169+
// AddressMaps should map addresses to signature slots in the same order as credentials
170+
const addressMaps = txCredentials.map((credential, credIdx) => {
173171
const addressMap = new FlareUtils.AddressMap();
174-
sortedAddresses.forEach((addr, i) => {
175-
addressMap.set(new Address(addr), i);
176-
});
172+
const utxo = this.transaction._utxos[credIdx];
173+
174+
// If UTXO has addresses, compute addressesIndex to determine signature order
175+
if (utxo && utxo.addresses && utxo.addresses.length > 0) {
176+
const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a));
177+
const addressesIndex = this.transaction._fromAddresses.map((a) =>
178+
utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0)
179+
);
180+
181+
const firstIndex = this.recoverSigner ? 2 : 0;
182+
const bitgoIndex = 1;
183+
184+
// Determine signature slot order based on addressesIndex (same logic as credentials)
185+
if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) {
186+
// Bitgo comes first: slot 0 = bitgo, slot 1 = firstIndex
187+
addressMap.set(new Address(this.transaction._fromAddresses[bitgoIndex]), 0);
188+
addressMap.set(new Address(this.transaction._fromAddresses[firstIndex]), 1);
189+
} else {
190+
// User/recovery comes first: slot 0 = firstIndex, slot 1 = bitgo
191+
addressMap.set(new Address(this.transaction._fromAddresses[firstIndex]), 0);
192+
addressMap.set(new Address(this.transaction._fromAddresses[bitgoIndex]), 1);
193+
}
194+
} else {
195+
// Fallback: map addresses sequentially if no UTXO addresses available
196+
this.transaction._fromAddresses.slice(0, this.transaction._threshold).forEach((addr, i) => {
197+
addressMap.set(new Address(addr), i);
198+
});
199+
}
200+
177201
return addressMap;
178202
});
179203

@@ -227,13 +251,40 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder {
227251
this.exportedOutputs() // exportedOutputs
228252
);
229253

230-
// Create address maps for signing - one per input/credential
231-
const sortedAddresses = [...this.transaction._fromAddresses].sort((a, b) => Buffer.compare(a, b));
232-
const addressMaps = credentials.map(() => {
254+
// Create AddressMaps based on signature slot order (matching credential order), not sorted addresses
255+
// This matches the approach used in credentials: addressesIndex determines signature order
256+
// AddressMaps should map addresses to signature slots in the same order as credentials
257+
const addressMaps = credentials.map((credential, credIdx) => {
233258
const addressMap = new FlareUtils.AddressMap();
234-
sortedAddresses.forEach((addr, i) => {
235-
addressMap.set(new Address(addr), i);
236-
});
259+
const utxo = this.transaction._utxos[credIdx];
260+
261+
// If UTXO has addresses, compute addressesIndex to determine signature order
262+
if (utxo && utxo.addresses && utxo.addresses.length > 0) {
263+
const utxoAddresses = utxo.addresses.map((a) => utils.parseAddress(a));
264+
const addressesIndex = this.transaction._fromAddresses.map((a) =>
265+
utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0)
266+
);
267+
268+
const firstIndex = this.recoverSigner ? 2 : 0;
269+
const bitgoIndex = 1;
270+
271+
// Determine signature slot order based on addressesIndex (same logic as credentials)
272+
if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) {
273+
// Bitgo comes first: slot 0 = bitgo, slot 1 = firstIndex
274+
addressMap.set(new Address(this.transaction._fromAddresses[bitgoIndex]), 0);
275+
addressMap.set(new Address(this.transaction._fromAddresses[firstIndex]), 1);
276+
} else {
277+
// User/recovery comes first: slot 0 = firstIndex, slot 1 = bitgo
278+
addressMap.set(new Address(this.transaction._fromAddresses[firstIndex]), 0);
279+
addressMap.set(new Address(this.transaction._fromAddresses[bitgoIndex]), 1);
280+
}
281+
} else {
282+
// Fallback: map addresses sequentially if no UTXO addresses available
283+
this.transaction._fromAddresses.slice(0, this.transaction._threshold).forEach((addr, i) => {
284+
addressMap.set(new Address(addr), i);
285+
});
286+
}
287+
237288
return addressMap;
238289
});
239290

modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,5 +200,190 @@ describe('Flrp Export In P Tx Builder', () => {
200200
rawTx.should.equal(signedExportHex);
201201
tx.id.should.equal('ka8at5CinmpUc6QMVr33dyUJi156LKMdodrJM59kS6EWr3vHg');
202202
});
203+
204+
it('should FAIL with unsorted UTXO addresses - demonstrates AddressMap mismatch issue for export in P-chain tx', async () => {
205+
// This test uses UTXO addresses in UNSORTED order to demonstrate the issue.
206+
// With unsorted addresses, the current implementation will create AddressMaps incorrectly
207+
// because it uses sorted addresses, not UTXO address order.
208+
//
209+
// Expected: AddressMap should map addresses to signature slots based on UTXO order (sigIndicies)
210+
// Current (WRONG): AddressMap uses sorted addresses with sequential slots
211+
//
212+
// This test WILL FAIL with current implementation because AddressMaps don't match sigIndicies
213+
214+
// UTXO addresses in UNSORTED order (different from sorted)
215+
// Sorted would be: [0x12cb... (smallest), 0xa6e0... (middle), 0xc386... (largest)]
216+
// Unsorted: [0xc386... (largest), 0x12cb... (smallest), 0xa6e0... (middle)]
217+
const unsortedUtxoAddresses = [
218+
'0xc386d58d09a9ae77cf1cf07bf1c9de44ebb0c9f3', // Largest (would be index 2 if sorted)
219+
'0x12cb32eaf92553064db98d271b56cba079ec78f5', // Smallest (would be index 0 if sorted)
220+
'0xa6e0c1abd0132f70efb77e2274637ff336a29a57', // Middle (would be index 1 if sorted)
221+
];
222+
223+
// Corresponding P-chain addresses (in same order as UTXO)
224+
const pAddresses = [
225+
'P-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8', // Maps to 0xc386... (UTXO index 0)
226+
'P-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd', // Maps to 0x12cb... (UTXO index 1)
227+
'P-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh', // Maps to 0xa6e0... (UTXO index 2)
228+
];
229+
230+
// Create UTXO with UNSORTED addresses
231+
// Amount must cover export amount + fee
232+
const exportAmount = '50000000';
233+
const fee = '1261000';
234+
const utxoAmount = (BigInt(exportAmount) + BigInt(fee)).toString(); // amount + fee
235+
236+
const utxo: DecodedUtxoObj = {
237+
outputID: 0,
238+
amount: utxoAmount,
239+
txid: 'zstyYq5riDKYDSR3fUYKKkuXKJ1aJCe8WNrXKqEBJD4CGwzFw',
240+
outputidx: '0',
241+
addresses: unsortedUtxoAddresses, // UNSORTED order
242+
threshold: 2,
243+
};
244+
245+
// Build transaction
246+
const txBuilder = factory
247+
.getExportInPBuilder()
248+
.threshold(2)
249+
.locktime(0)
250+
.fromPubKey(pAddresses)
251+
.externalChainId(testData.sourceChainId)
252+
.amount(exportAmount)
253+
.fee(fee)
254+
.utxos([utxo]);
255+
256+
// Build unsigned transaction
257+
const unsignedTx = await txBuilder.build();
258+
const unsignedHex = unsignedTx.toBroadcastFormat();
259+
260+
// Parse it back to inspect AddressMaps and sigIndicies
261+
const parsedBuilder = factory.from(unsignedHex);
262+
const parsedTx = await parsedBuilder.build();
263+
const flareTx = (parsedTx as any)._flareTransaction;
264+
265+
// Get the input to check sigIndicies
266+
const exportTx = flareTx.tx as any;
267+
const input = exportTx.baseTx.inputs[0];
268+
const transferInput = input.input;
269+
const sigIndicies = transferInput.sigIndicies();
270+
271+
// sigIndicies tells us: sigIndicies[slotIndex] = utxoAddressIndex
272+
// For threshold=2, we need signatures for first 2 addresses in UTXO order
273+
// UTXO order: [0xc386... (index 0), 0x12cb... (index 1), 0xa6e0... (index 2)]
274+
// So sigIndicies should be [0, 1] meaning: slot 0 = UTXO index 0, slot 1 = UTXO index 1
275+
276+
// Verify sigIndicies are [0, 1] (first 2 addresses in UTXO order, NOT sorted order)
277+
sigIndicies.length.should.equal(2);
278+
sigIndicies[0].should.equal(0, 'First signature slot should be UTXO address index 0 (0xc386...)');
279+
sigIndicies[1].should.equal(1, 'Second signature slot should be UTXO address index 1 (0x12cb...)');
280+
281+
// The critical test: Verify that signature slots have embedded addresses based on UTXO order
282+
// With unsorted UTXO addresses, this will FAIL if AddressMaps don't match UTXO order
283+
//
284+
// sigIndicies tells us: sigIndicies[slotIndex] = utxoAddressIndex
285+
// For threshold=2, we need signatures for first 2 addresses in UTXO order
286+
// UTXO order: [0xc386... (index 0), 0x12cb... (index 1), 0xa6e0... (index 2)]
287+
// So sigIndicies should be [0, 1] meaning: slot 0 = UTXO index 0, slot 1 = UTXO index 1
288+
289+
// Parse the credential to see which slots have which embedded addresses
290+
const credential = flareTx.credentials[0];
291+
const signatures = credential.getSignatures();
292+
293+
// Helper function to check if signature has embedded address (same logic as transaction.ts)
294+
const testUtils2 = require('../../../src/lib/utils').default;
295+
const isEmptySignature = (signature: string): boolean => {
296+
return !!signature && testUtils2.removeHexPrefix(signature).startsWith('0'.repeat(90));
297+
};
298+
299+
const hasEmbeddedAddress = (signature: string): boolean => {
300+
if (!isEmptySignature(signature)) return false;
301+
const cleanSig = testUtils2.removeHexPrefix(signature);
302+
if (cleanSig.length < 130) return false;
303+
const embeddedPart = cleanSig.substring(90, 130);
304+
// Check if embedded part is not all zeros
305+
return embeddedPart !== '0'.repeat(40);
306+
};
307+
308+
// Extract embedded addresses from signature slots
309+
const embeddedAddresses: string[] = [];
310+
311+
signatures.forEach((sig: string, slotIndex: number) => {
312+
if (hasEmbeddedAddress(sig)) {
313+
// Extract embedded address (after position 90, 40 chars = 20 bytes)
314+
const cleanSig = testUtils2.removeHexPrefix(sig);
315+
const embeddedAddr = cleanSig.substring(90, 130).toLowerCase();
316+
embeddedAddresses[slotIndex] = '0x' + embeddedAddr;
317+
}
318+
});
319+
320+
// Verify: Credentials only embed ONE address (user/recovery), not both
321+
// The embedded address should be based on addressesIndex logic, not sorted order
322+
//
323+
// Compute addressesIndex to determine expected signature order
324+
const utxoAddressBytes = unsortedUtxoAddresses.map((addr) => testUtils2.parseAddress(addr));
325+
const pAddressBytes = pAddresses.map((addr) => testUtils2.parseAddress(addr));
326+
327+
const addressesIndex: number[] = [];
328+
pAddressBytes.forEach((pAddr) => {
329+
const utxoIndex = utxoAddressBytes.findIndex(
330+
(uAddr) => Buffer.compare(Buffer.from(uAddr), Buffer.from(pAddr)) === 0
331+
);
332+
addressesIndex.push(utxoIndex);
333+
});
334+
335+
// firstIndex = 0 (user), bitgoIndex = 1
336+
const firstIndex = 0;
337+
const bitgoIndex = 1;
338+
339+
// Determine expected signature order based on addressesIndex
340+
const userComesFirst = addressesIndex[bitgoIndex] > addressesIndex[firstIndex];
341+
342+
// Expected credential structure:
343+
// - If user comes first: [userAddress, zeros]
344+
// - If bitgo comes first: [zeros, userAddress]
345+
const userAddressHex = Buffer.from(pAddressBytes[firstIndex]).toString('hex').toLowerCase();
346+
const expectedUserAddr = '0x' + userAddressHex;
347+
348+
if (userComesFirst) {
349+
// Expected: [userAddress, zeros]
350+
// Slot 0 should have user address (pAddr0 = 0xc386... = UTXO index 0)
351+
if (embeddedAddresses[0]) {
352+
embeddedAddresses[0]
353+
.toLowerCase()
354+
.should.equal(
355+
expectedUserAddr,
356+
`Slot 0 should have user address (${expectedUserAddr}) because user comes first in UTXO order`
357+
);
358+
} else {
359+
throw new Error(`Slot 0 should have embedded user address, but is empty`);
360+
}
361+
// Slot 1 should be zeros (no embedded address)
362+
if (embeddedAddresses[1]) {
363+
throw new Error(`Slot 1 should be zeros, but has embedded address: ${embeddedAddresses[1]}`);
364+
}
365+
} else {
366+
// Expected: [zeros, userAddress]
367+
// Slot 0 should be zeros
368+
if (embeddedAddresses[0]) {
369+
throw new Error(`Slot 0 should be zeros, but has embedded address: ${embeddedAddresses[0]}`);
370+
}
371+
// Slot 1 should have user address
372+
if (embeddedAddresses[1]) {
373+
embeddedAddresses[1]
374+
.toLowerCase()
375+
.should.equal(
376+
expectedUserAddr,
377+
`Slot 1 should have user address (${expectedUserAddr}) because bitgo comes first in UTXO order`
378+
);
379+
} else {
380+
throw new Error(`Slot 1 should have embedded user address, but is empty`);
381+
}
382+
}
383+
384+
// The key verification: AddressMaps should match the credential order
385+
// With the fix, AddressMaps are created using the same addressesIndex logic as credentials
386+
// This ensures signing works correctly even with unsorted UTXO addresses
387+
});
203388
});
204389
});

modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts

Lines changed: 0 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -142,89 +142,6 @@ describe('Flrp Import In P Tx Builder', () => {
142142
tx.id.should.equal('2vwvuXp47dsUmqb4vkaMk7UsukrZNapKXT2ruZhVibbjMDpqr9');
143143
});
144144

145-
it('should create AddressMaps based on UTXO address order, not sorted addresses', async () => {
146-
// This test verifies that AddressMaps correctly map addresses to signature slots
147-
// based on UTXO address order (via sigIndicies), not sorted address order.
148-
//
149-
// The issue: Current implementation uses sorted addresses with sequential indices (0,1,2...)
150-
// But credentials use UTXO address order, causing a mismatch.
151-
//
152-
// Test setup: Parse an existing transaction to inspect how AddressMaps are created
153-
// Expected: AddressMap should map addresses to signature slots based on UTXO order (sigIndicies)
154-
// Current behavior: AddressMap uses sorted addresses with sequential indices (WRONG)
155-
156-
// Use the existing test data which has UTXO addresses in sorted order
157-
// But we'll verify that AddressMaps correctly reflect UTXO order, not just sorted order
158-
const unsignedHex = testData.unsignedHex;
159-
160-
// Parse the unsigned transaction
161-
const parsedBuilder = new TransactionBuilderFactory(coins.get('tflrp')).from(unsignedHex);
162-
const parsedTx = await parsedBuilder.build();
163-
const flareTx = (parsedTx as any)._flareTransaction;
164-
165-
// Get the input to check sigIndicies
166-
const importTx = flareTx.tx as any;
167-
const input = importTx.ins[0];
168-
const transferInput = input.input;
169-
const sigIndicies = transferInput.sigIndicies();
170-
171-
// sigIndicies tells us: sigIndicies[slotIndex] = utxoAddressIndex
172-
// For threshold=2, we need signatures for first 2 addresses in UTXO order
173-
// UTXO addresses from testData: [0x12cb... (index 0), 0xa6e0... (index 1), 0xc386... (index 2)]
174-
// These happen to be in sorted order, so sigIndicies should be [0, 1]
175-
176-
// Verify sigIndicies are [0, 1] (first 2 addresses in UTXO order)
177-
sigIndicies.length.should.equal(2);
178-
sigIndicies[0].should.equal(0); // First signature slot = UTXO address index 0
179-
sigIndicies[1].should.equal(1); // Second signature slot = UTXO address index 1
180-
181-
// Now verify AddressMap: it should map addresses to signature slots based on UTXO order
182-
// NOT based on sorted order
183-
//
184-
// The issue: Current implementation (line 95-96 in ImportInPTxBuilder.ts) creates AddressMaps like:
185-
// sortedAddresses.map((a, i) => new AddressMap([[new Address(a), i]]))
186-
// This maps sorted addresses to sequential slots (0, 1, 2...)
187-
//
188-
// But credentials use UTXO address order via addressesIndex, which means:
189-
// - Credential signature slots are ordered based on UTXO address positions
190-
// - AddressMaps should match this order, not sorted order
191-
//
192-
// Expected: AddressMap should map addresses to signature slots based on sigIndicies
193-
// sigIndicies[slotIndex] = utxoAddressIndex tells us which UTXO address is at each signature slot
194-
// So AddressMap should map: address at UTXO index sigIndicies[0] -> slot 0, etc.
195-
196-
// Get addresses from the map to verify they're present
197-
const addressesInMap = flareTx.getAddresses();
198-
addressesInMap.length.should.be.greaterThan(0, 'AddressMap should contain addresses');
199-
200-
// Parse addresses to compare
201-
202-
const testPAddresses = testData.pAddresses;
203-
204-
const testPAddr0Bytes = testUtils.parseAddress(testPAddresses[0]); // Corresponds to UTXO index 0
205-
const testPAddr1Bytes = testUtils.parseAddress(testPAddresses[1]); // Corresponds to UTXO index 1
206-
207-
// Verify addresses are in the map
208-
const addr0InMap = addressesInMap.some((addr) => Buffer.compare(Buffer.from(addr), testPAddr0Bytes) === 0);
209-
const addr1InMap = addressesInMap.some((addr) => Buffer.compare(Buffer.from(addr), testPAddr1Bytes) === 0);
210-
211-
addr0InMap.should.be.true('Address at UTXO index 0 should be in AddressMap');
212-
addr1InMap.should.be.true('Address at UTXO index 1 should be in AddressMap');
213-
214-
// The key issue: AddressMaps are created with sorted addresses mapped to sequential slots
215-
// But they should be mapped based on sigIndicies (UTXO order)
216-
//
217-
// Current (WRONG): sorted[0] -> slot 0, sorted[1] -> slot 1
218-
// Expected (CORRECT): address at UTXO index sigIndicies[0] -> slot 0, etc.
219-
//
220-
// This test documents the expected behavior. The actual fix will be in ImportInPTxBuilder.ts
221-
// where AddressMaps are created based on sigIndicies, not sorted addresses.
222-
//
223-
// NOTE: This test may pass even with the current implementation if UTXO addresses happen to be sorted.
224-
// To truly test the issue, we'd need UTXO addresses in UNSORTED order, which would cause
225-
// AddressMaps to be wrong even though sigIndicies are correct.
226-
});
227-
228145
it('should FAIL with unsorted UTXO addresses - demonstrates AddressMap mismatch issue', async () => {
229146
// This test uses UTXO addresses in UNSORTED order to demonstrate the issue.
230147
// With unsorted addresses, the current implementation will create AddressMaps incorrectly

0 commit comments

Comments
 (0)