Skip to content

Commit 27529df

Browse files
committed
fix(sdk-coin-flrp): enhance AddressMap creation to ensure correct mapping based on UTXO order
TICKET: WIN-8279
1 parent c4e94e7 commit 27529df

File tree

2 files changed

+325
-7
lines changed

2 files changed

+325
-7
lines changed

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

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,53 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder {
8787
const inputThreshold = firstInput.sigIndicies().length || this.transaction._threshold;
8888
this.transaction._threshold = inputThreshold;
8989

90-
// Create proper UnsignedTx wrapper with credentials
91-
const toAddress = new Address(output.address.toBytes());
92-
const addressMap = new FlareUtils.AddressMap([[toAddress, 0]]);
90+
// Create AddressMaps based on signature slot order (matching credential order), not sorted addresses
91+
// This matches the approach used in credentials: addressesIndex determines signature order
92+
// AddressMaps should map addresses to signature slots in the same order as credentials
93+
const addressMap = new FlareUtils.AddressMap();
94+
95+
// If _fromAddresses is available, create AddressMap based on UTXO order (matching credential order)
96+
// Otherwise, fall back to mapping just the output address
97+
const firstUtxo = this.transaction._utxos[0];
98+
if (
99+
firstUtxo &&
100+
firstUtxo.addresses &&
101+
firstUtxo.addresses.length > 0 &&
102+
this.transaction._fromAddresses &&
103+
this.transaction._fromAddresses.length >= this.transaction._threshold
104+
) {
105+
const utxoAddresses = firstUtxo.addresses.map((a) => utils.parseAddress(a));
106+
const addressesIndex = this.transaction._fromAddresses.map((a) =>
107+
utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0)
108+
);
109+
110+
const firstIndex = this.recoverSigner ? 2 : 0;
111+
const bitgoIndex = 1;
112+
113+
// Determine signature slot order based on addressesIndex (same logic as credentials)
114+
if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) {
115+
// Bitgo comes first: slot 0 = bitgo, slot 1 = firstIndex
116+
addressMap.set(new Address(this.transaction._fromAddresses[bitgoIndex]), 0);
117+
addressMap.set(new Address(this.transaction._fromAddresses[firstIndex]), 1);
118+
} else {
119+
// User/recovery comes first: slot 0 = firstIndex, slot 1 = bitgo
120+
addressMap.set(new Address(this.transaction._fromAddresses[firstIndex]), 0);
121+
addressMap.set(new Address(this.transaction._fromAddresses[bitgoIndex]), 1);
122+
}
123+
} else {
124+
// Fallback: map output address to slot 0 (for C-chain imports, output is the destination)
125+
// Or map addresses sequentially if _fromAddresses is available but UTXO addresses are not
126+
if (this.transaction._fromAddresses && this.transaction._fromAddresses.length >= this.transaction._threshold) {
127+
this.transaction._fromAddresses.slice(0, this.transaction._threshold).forEach((addr, i) => {
128+
addressMap.set(new Address(addr), i);
129+
});
130+
} else {
131+
// Last resort: map output address
132+
const toAddress = new Address(output.address.toBytes());
133+
addressMap.set(toAddress, 0);
134+
}
135+
}
136+
93137
const addressMaps = new FlareUtils.AddressMaps([addressMap]);
94138

95139
// When credentials were extracted, use them directly to preserve existing signatures
@@ -159,11 +203,39 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder {
159203
[output]
160204
);
161205

162-
// Create unsigned transaction with all potential signers in address map
206+
// Create AddressMaps based on signature slot order (matching credential order), not sorted addresses
207+
// This matches the approach used in credentials: addressesIndex determines signature order
208+
// AddressMaps should map addresses to signature slots in the same order as credentials
163209
const addressMap = new FlareUtils.AddressMap();
164-
this.transaction._fromAddresses.forEach((addr, i) => {
165-
addressMap.set(new Address(addr), i);
166-
});
210+
211+
// For C-chain imports, we typically have one input, so use the first UTXO to determine address order
212+
const firstUtxo = this.transaction._utxos[0];
213+
if (firstUtxo && firstUtxo.addresses && firstUtxo.addresses.length > 0) {
214+
const utxoAddresses = firstUtxo.addresses.map((a) => utils.parseAddress(a));
215+
const addressesIndex = this.transaction._fromAddresses.map((a) =>
216+
utxoAddresses.findIndex((u) => Buffer.compare(Buffer.from(u), Buffer.from(a)) === 0)
217+
);
218+
219+
const firstIndex = this.recoverSigner ? 2 : 0;
220+
const bitgoIndex = 1;
221+
222+
// Determine signature slot order based on addressesIndex (same logic as credentials)
223+
if (addressesIndex[bitgoIndex] < addressesIndex[firstIndex]) {
224+
// Bitgo comes first: slot 0 = bitgo, slot 1 = firstIndex
225+
addressMap.set(new Address(this.transaction._fromAddresses[bitgoIndex]), 0);
226+
addressMap.set(new Address(this.transaction._fromAddresses[firstIndex]), 1);
227+
} else {
228+
// User/recovery comes first: slot 0 = firstIndex, slot 1 = bitgo
229+
addressMap.set(new Address(this.transaction._fromAddresses[firstIndex]), 0);
230+
addressMap.set(new Address(this.transaction._fromAddresses[bitgoIndex]), 1);
231+
}
232+
} else {
233+
// Fallback: map addresses sequentially if no UTXO addresses available
234+
this.transaction._fromAddresses.slice(0, this.transaction._threshold).forEach((addr, i) => {
235+
addressMap.set(new Address(addr), i);
236+
});
237+
}
238+
167239
const addressMaps = new FlareUtils.AddressMaps([addressMap]);
168240

169241
const unsignedTx = new UnsignedTx(

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

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { TransactionBuilderFactory, DecodedUtxoObj } from '../../../src/lib';
44
import { coins } from '@bitgo/statics';
55
import { IMPORT_IN_C as testData } from '../../resources/transactionData/importInC';
66
import signFlowTest from './signFlowTestSuit';
7+
import { UnsignedTx } from '@flarenetwork/flarejs';
8+
import testUtils from '../../../src/lib/utils';
79

810
describe('Flrp Import In C Tx Builder', () => {
911
const factory = new TransactionBuilderFactory(coins.get('tflrp'));
@@ -60,5 +62,249 @@ describe('Flrp Import In C Tx Builder', () => {
6062
rawTx.should.equal(signedImportHex);
6163
tx.id.should.equal('2ks9vW1SVWD4KsNPHgXnV5dpJaCcaxVNbQW4H7t9BMDxApGvfa');
6264
});
65+
66+
it('should FAIL with unsorted UTXO addresses - demonstrates AddressMap mismatch issue for import in C-chain tx', async () => {
67+
// This test uses UTXO addresses in UNSORTED order to demonstrate the issue.
68+
// With unsorted addresses, the current implementation will create AddressMaps incorrectly
69+
// because it uses sequential indices, not UTXO address order.
70+
//
71+
// Expected: AddressMap should map addresses to signature slots based on UTXO order (addressesIndex)
72+
// Current (WRONG): AddressMap uses sequential indices (0, 1, 2...)
73+
//
74+
// This test WILL FAIL with current implementation because AddressMaps don't match credential order
75+
76+
// UTXO addresses in UNSORTED order (different from sorted)
77+
// Sorted would be: [0x3329... (smallest), 0x7e91... (middle), 0xc732... (largest)]
78+
// Unsorted: [0xc732... (largest), 0x3329... (smallest), 0x7e91... (middle)]
79+
const unsortedUtxoAddresses = [
80+
'0xc7324437c96c7c8a6a152da2385c1db5c3ab1f91', // Largest (would be index 2 if sorted)
81+
'0x3329be7d01cd3ebaae6654d7327dd9f17a2e1581', // Smallest (would be index 0 if sorted)
82+
'0x7e918a5e8083ae4c9f2f0ed77055c24bf3665001', // Middle (would be index 1 if sorted)
83+
];
84+
85+
// Corresponding P-chain addresses (in same order as _fromAddresses)
86+
const pAddresses = [
87+
'P-costwo1xv5mulgpe5lt4tnx2ntnylwe79azu9vpja6lut', // Maps to 0xc732... (UTXO index 0 in unsorted)
88+
'P-costwo106gc5h5qswhye8e0pmthq4wzf0ekv5qppsrvpu', // Maps to 0x3329... (UTXO index 1 in unsorted)
89+
'P-costwo1cueygd7fd37g56s49k3rshqakhp6k8u3adzt6m', // Maps to 0x7e91... (UTXO index 2 in unsorted)
90+
];
91+
92+
// Create UTXO with UNSORTED addresses
93+
const amount = '500000000'; // 0.5 FLR
94+
const fee = '5000000'; // Example fee
95+
const utxoAmount = (BigInt(amount) + BigInt(fee) + BigInt('10000000')).toString(); // amount + fee + some buffer
96+
97+
const utxo: DecodedUtxoObj = {
98+
outputID: 0,
99+
amount: utxoAmount,
100+
txid: '2vPMx8P63adgBae7GAWFx7qvJDwRmMnDCyKddHRBXWhysjX4BP',
101+
outputidx: '1',
102+
addresses: unsortedUtxoAddresses, // UNSORTED order
103+
threshold: 2,
104+
};
105+
106+
// Build transaction
107+
const txBuilder = factory
108+
.getImportInCBuilder()
109+
.threshold(2)
110+
.fromPubKey(pAddresses)
111+
.utxos([utxo])
112+
.to(testData.to)
113+
.feeRate(testData.fee);
114+
115+
// Build unsigned transaction
116+
const unsignedTx = await txBuilder.build();
117+
const unsignedHex = unsignedTx.toBroadcastFormat();
118+
119+
// Get AddressMaps from the ORIGINAL transaction (before parsing)
120+
// The parsed transaction's AddressMap only contains the output address, not _fromAddresses
121+
const originalFlareTx = (unsignedTx as any)._flareTransaction;
122+
const originalAddressMaps = (originalFlareTx as any as UnsignedTx).addressMaps;
123+
124+
// Parse it back to inspect AddressMaps and credentials
125+
const parsedBuilder = factory.from(unsignedHex);
126+
const parsedTx = await parsedBuilder.build();
127+
const flareTx = (parsedTx as any)._flareTransaction;
128+
129+
// Get the input to check sigIndicies (for C-chain imports, inputs are importedInputs)
130+
const importTx = flareTx.tx as any;
131+
const input = importTx.importedInputs[0];
132+
const sigIndicies = input.sigIndicies();
133+
134+
// sigIndicies tells us: sigIndicies[slotIndex] = utxoAddressIndex
135+
// For threshold=2, we need signatures for first 2 addresses in UTXO order
136+
// UTXO order: [0xc732... (index 0), 0x3329... (index 1), 0x7e91... (index 2)]
137+
// So sigIndicies should be [0, 1] meaning: slot 0 = UTXO index 0, slot 1 = UTXO index 1
138+
139+
// Verify sigIndicies are [0, 1] (first 2 addresses in UTXO order, NOT sorted order)
140+
sigIndicies.length.should.equal(2);
141+
sigIndicies[0].should.equal(0, 'First signature slot should be UTXO address index 0 (0xc732...)');
142+
sigIndicies[1].should.equal(1, 'Second signature slot should be UTXO address index 1 (0x3329...)');
143+
144+
// The critical test: Verify that signature slots have embedded addresses based on UTXO order
145+
// With unsorted UTXO addresses, this will FAIL if AddressMaps don't match UTXO order
146+
//
147+
// Parse the credential to see which slots have which embedded addresses
148+
const credential = flareTx.credentials[0];
149+
const signatures = credential.getSignatures();
150+
151+
// Extract embedded addresses from signature slots
152+
const embeddedAddresses: string[] = [];
153+
const isEmptySignature = (signature: string): boolean => {
154+
return !!signature && testUtils.removeHexPrefix(signature).startsWith('0'.repeat(90));
155+
};
156+
157+
const hasEmbeddedAddress = (signature: string): boolean => {
158+
if (!isEmptySignature(signature)) return false;
159+
const cleanSig = testUtils.removeHexPrefix(signature);
160+
if (cleanSig.length < 130) return false;
161+
const embeddedPart = cleanSig.substring(90, 130);
162+
// Check if embedded part is not all zeros
163+
return embeddedPart !== '0'.repeat(40);
164+
};
165+
166+
signatures.forEach((sig: string, slotIndex: number) => {
167+
if (hasEmbeddedAddress(sig)) {
168+
// Extract embedded address (after position 90, 40 chars = 20 bytes)
169+
const cleanSig = testUtils.removeHexPrefix(sig);
170+
const embeddedAddr = cleanSig.substring(90, 130).toLowerCase();
171+
embeddedAddresses[slotIndex] = '0x' + embeddedAddr;
172+
}
173+
});
174+
175+
// Verify: Credentials only embed ONE address (user/recovery), not both
176+
// The embedded address should be based on addressesIndex logic, not sequential order
177+
//
178+
// Compute addressesIndex to determine expected signature order
179+
const utxoAddressBytes = unsortedUtxoAddresses.map((addr) => testUtils.parseAddress(addr));
180+
const pAddressBytes = pAddresses.map((addr) => testUtils.parseAddress(addr));
181+
182+
const addressesIndex: number[] = [];
183+
pAddressBytes.forEach((pAddr) => {
184+
const utxoIndex = utxoAddressBytes.findIndex(
185+
(uAddr) => Buffer.compare(Buffer.from(uAddr), Buffer.from(pAddr)) === 0
186+
);
187+
addressesIndex.push(utxoIndex);
188+
});
189+
190+
// firstIndex = 0 (user), bitgoIndex = 1
191+
const firstIndex = 0;
192+
const bitgoIndex = 1;
193+
194+
// Determine expected signature order based on addressesIndex
195+
const userComesFirst = addressesIndex[bitgoIndex] > addressesIndex[firstIndex];
196+
197+
// Expected credential structure:
198+
// - If user comes first: [userAddress, zeros]
199+
// - If bitgo comes first: [zeros, userAddress]
200+
const userAddressHex = Buffer.from(pAddressBytes[firstIndex]).toString('hex').toLowerCase();
201+
const expectedUserAddr = '0x' + userAddressHex;
202+
203+
if (userComesFirst) {
204+
// Expected: [userAddress, zeros]
205+
// Slot 0 should have user address (pAddr0 = 0xc732... = UTXO index 0)
206+
if (embeddedAddresses[0]) {
207+
embeddedAddresses[0]
208+
.toLowerCase()
209+
.should.equal(
210+
expectedUserAddr,
211+
`Slot 0 should have user address (${expectedUserAddr}) because user comes first in UTXO order`
212+
);
213+
} else {
214+
throw new Error(`Slot 0 should have embedded user address, but is empty`);
215+
}
216+
// Slot 1 should be zeros (no embedded address)
217+
if (embeddedAddresses[1]) {
218+
throw new Error(`Slot 1 should be zeros, but has embedded address: ${embeddedAddresses[1]}`);
219+
}
220+
} else {
221+
// Expected: [zeros, userAddress]
222+
// Slot 0 should be zeros
223+
if (embeddedAddresses[0]) {
224+
throw new Error(`Slot 0 should be zeros, but has embedded address: ${embeddedAddresses[0]}`);
225+
}
226+
// Slot 1 should have user address
227+
if (embeddedAddresses[1]) {
228+
embeddedAddresses[1]
229+
.toLowerCase()
230+
.should.equal(
231+
expectedUserAddr,
232+
`Slot 1 should have user address (${expectedUserAddr}) because bitgo comes first in UTXO order`
233+
);
234+
} else {
235+
throw new Error(`Slot 1 should have embedded user address, but is empty`);
236+
}
237+
}
238+
239+
// The key verification: AddressMaps should match the credential order
240+
// Current implementation (WRONG): AddressMaps use sequential indices (0, 1, 2...)
241+
// Expected (CORRECT): AddressMaps should use addressesIndex logic, matching credential order
242+
//
243+
// Get AddressMaps from the ORIGINAL transaction (not parsed, because parsed AddressMap only has output address)
244+
// For C-chain imports, originalFlareTx is EVMUnsignedTx which has addressMaps property
245+
246+
const addressMaps = originalAddressMaps;
247+
addressMaps.toArray().length.should.equal(1, 'Should have one AddressMap for one input');
248+
249+
const addressMap = addressMaps.toArray()[0];
250+
251+
// Expected: Based on addressesIndex logic
252+
// If user comes first: slot 0 = user, slot 1 = bitgo
253+
// If bitgo comes first: slot 0 = bitgo, slot 1 = user
254+
const expectedSlot0Addr = userComesFirst ? pAddressBytes[firstIndex] : pAddressBytes[bitgoIndex];
255+
const expectedSlot1Addr = userComesFirst ? pAddressBytes[bitgoIndex] : pAddressBytes[firstIndex];
256+
257+
// AddressMap maps: Address -> slot index
258+
// We need to check which addresses are mapped to slots 0 and 1
259+
// AddressMap.get() returns the slot index for a given address
260+
261+
// Verify that AddressMap correctly maps addresses based on credential order (UTXO order)
262+
// The AddressMap should map the addresses that appear in credentials to the correct slots
263+
const { Address } = require('@flarenetwork/flarejs');
264+
const expectedSlot0Address = new Address(expectedSlot0Addr);
265+
const expectedSlot1Address = new Address(expectedSlot1Addr);
266+
const expectedSlot0FromMap = addressMap.get(expectedSlot0Address);
267+
const expectedSlot1FromMap = addressMap.get(expectedSlot1Address);
268+
269+
// Verify that the expected addresses map to the correct slots
270+
if (expectedSlot0FromMap === undefined) {
271+
throw new Error(`Address at UTXO index ${addressesIndex[firstIndex]} not found in AddressMap`);
272+
}
273+
if (expectedSlot1FromMap === undefined) {
274+
throw new Error(`Address at UTXO index ${addressesIndex[bitgoIndex]} not found in AddressMap`);
275+
}
276+
expectedSlot0FromMap.should.equal(0, `Address at UTXO index ${addressesIndex[firstIndex]} should map to slot 0`);
277+
expectedSlot1FromMap.should.equal(1, `Address at UTXO index ${addressesIndex[bitgoIndex]} should map to slot 1`);
278+
279+
// If addressesIndex is not sequential ([0, 1, ...]), verify that sequential mapping is NOT used incorrectly
280+
// Sequential mapping means: pAddresses[0] -> slot 0, pAddresses[1] -> slot 1, regardless of UTXO order
281+
const usesSequentialMapping = addressesIndex[0] === 0 && addressesIndex[1] === 1;
282+
283+
if (!usesSequentialMapping) {
284+
// Check if AddressMap uses sequential mapping (array order) instead of UTXO order
285+
const sequentialSlot0 = addressMap.get(new Address(pAddressBytes[0]));
286+
const sequentialSlot1 = addressMap.get(new Address(pAddressBytes[1]));
287+
288+
// Sequential mapping would map pAddresses[0] -> slot 0, pAddresses[1] -> slot 1
289+
// But we want UTXO order mapping based on addressesIndex
290+
const isSequential = sequentialSlot0 === 0 && sequentialSlot1 === 1;
291+
292+
// Check if pAddresses[0] and pAddresses[1] are the expected addresses for slots 0 and 1
293+
// If they are, then sequential mapping happens to be correct (by coincidence)
294+
const pAddress0IsExpectedSlot0 =
295+
Buffer.compare(Buffer.from(pAddressBytes[0]), Buffer.from(expectedSlot0Addr)) === 0;
296+
const pAddress1IsExpectedSlot1 =
297+
Buffer.compare(Buffer.from(pAddressBytes[1]), Buffer.from(expectedSlot1Addr)) === 0;
298+
299+
// If sequential mapping is used but it's NOT correct (doesn't match expected addresses), fail
300+
if (isSequential && (!pAddress0IsExpectedSlot0 || !pAddress1IsExpectedSlot1)) {
301+
throw new Error(
302+
`AddressMap uses sequential mapping (array order) but should use UTXO order. ` +
303+
`addressesIndex: [${addressesIndex.join(', ')}]. ` +
304+
`Expected slot 0 = address at UTXO index ${addressesIndex[firstIndex]}, slot 1 = address at UTXO index ${addressesIndex[bitgoIndex]}`
305+
);
306+
}
307+
}
308+
});
63309
});
64310
});

0 commit comments

Comments
 (0)