Skip to content

Commit 7349d03

Browse files
Merge pull request #7952 from BitGo/WIN-8686
fix(sdk-coin-flrp): update UTXO matching logic
2 parents c58e542 + 5fde79f commit 7349d03

4 files changed

Lines changed: 335 additions & 6 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,18 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder {
166166
const flareUnsignedTx = exportTx as UnsignedTx;
167167
const innerTx = flareUnsignedTx.getTx() as pvmSerial.ExportTx;
168168

169-
const utxosWithIndex = innerTx.baseTx.inputs.map((input, idx) => {
170-
const originalUtxo = this.transaction._utxos[idx];
169+
const utxosWithIndex = innerTx.baseTx.inputs.map((input) => {
170+
const inputTxid = utils.cb58Encode(Buffer.from(input.utxoID.txID.toBytes()));
171+
const inputOutputIdx = input.utxoID.outputIdx.value().toString();
172+
173+
const originalUtxo = this.transaction._utxos.find(
174+
(utxo) => utxo.txid === inputTxid && utxo.outputidx === inputOutputIdx
175+
);
176+
177+
if (!originalUtxo) {
178+
throw new BuildTransactionError(`Could not find matching UTXO for input ${inputTxid}:${inputOutputIdx}`);
179+
}
180+
171181
return {
172182
...originalUtxo,
173183
addressesIndex: originalUtxo.addressesIndex,

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,18 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder {
166166
const flareUnsignedTx = importTx as UnsignedTx;
167167
const innerTx = flareUnsignedTx.getTx() as evmSerial.ImportTx;
168168

169-
const utxosWithIndex = innerTx.importedInputs.map((input, idx) => {
170-
const originalUtxo = this.transaction._utxos[idx];
169+
const utxosWithIndex = innerTx.importedInputs.map((input) => {
170+
const inputTxid = utils.cb58Encode(Buffer.from(input.utxoID.txID.toBytes()));
171+
const inputOutputIdx = input.utxoID.outputIdx.value().toString();
172+
173+
const originalUtxo = this.transaction._utxos.find(
174+
(utxo) => utxo.txid === inputTxid && utxo.outputidx === inputOutputIdx
175+
);
176+
177+
if (!originalUtxo) {
178+
throw new BuildTransactionError(`Could not find matching UTXO for input ${inputTxid}:${inputOutputIdx}`);
179+
}
180+
171181
return {
172182
...originalUtxo,
173183
addressesIndex: originalUtxo.addressesIndex,

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,18 @@ export class ImportInPTxBuilder extends AtomicTransactionBuilder {
197197
const flareUnsignedTx = importTx as UnsignedTx;
198198
const innerTx = flareUnsignedTx.getTx() as pvmSerial.ImportTx;
199199

200-
const utxosWithIndex = innerTx.ins.map((input, idx) => {
201-
const originalUtxo = this.transaction._utxos[idx];
200+
const utxosWithIndex = innerTx.ins.map((input) => {
201+
const inputTxid = utils.cb58Encode(Buffer.from(input.utxoID.txID.toBytes()));
202+
const inputOutputIdx = input.utxoID.outputIdx.value().toString();
203+
204+
const originalUtxo = this.transaction._utxos.find(
205+
(utxo) => utxo.txid === inputTxid && utxo.outputidx === inputOutputIdx
206+
);
207+
208+
if (!originalUtxo) {
209+
throw new BuildTransactionError(`Could not find matching UTXO for input ${inputTxid}:${inputOutputIdx}`);
210+
}
211+
202212
return {
203213
...originalUtxo,
204214
addressesIndex: originalUtxo.addressesIndex,

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

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,4 +694,303 @@ describe('Signature Index Handling - AVAX P Alignment', () => {
694694
fullSignedTx.id.should.equal(importPTestData.txhash);
695695
});
696696
});
697+
698+
/**
699+
* Test suite for UTXO reordering fix.
700+
*
701+
* FlareJS's newImportTx/newExportTx functions sort inputs by UTXO ID (txid + outputidx)
702+
* for deterministic transaction building. The SDK must match inputs back to UTXOs
703+
* by UTXO ID, not by array index, to ensure credentials are created for the correct inputs.
704+
*
705+
* These tests verify that transactions with multiple UTXOs work correctly regardless
706+
* of the order in which UTXOs are provided.
707+
*/
708+
describe('UTXO Reordering Fix - Multiple UTXOs with Different txids', () => {
709+
describe('ImportInC with reordered UTXOs', () => {
710+
it('should correctly handle multiple UTXOs that may get reordered by FlareJS', async () => {
711+
const reorderedUtxos = [importCTestData.utxos[4], importCTestData.utxos[0]];
712+
713+
const txBuilder = newFactory()
714+
.getImportInCBuilder()
715+
.threshold(importCTestData.threshold)
716+
.fromPubKey(importCTestData.pAddresses)
717+
.decodedUtxos(reorderedUtxos)
718+
.to(importCTestData.to)
719+
.fee(importCTestData.fee)
720+
.context(importCTestData.context);
721+
722+
txBuilder.sign({ key: importCTestData.privateKeys[2] });
723+
txBuilder.sign({ key: importCTestData.privateKeys[0] });
724+
725+
const tx = await txBuilder.build();
726+
const txJson = tx.toJson();
727+
728+
txJson.signatures.length.should.equal(2);
729+
tx.toBroadcastFormat().should.be.a.String();
730+
txJson.inputs.length.should.equal(2);
731+
});
732+
733+
it('should correctly sign in parse-sign-parse-sign flow with multiple UTXOs', async () => {
734+
const reorderedUtxos = [importCTestData.utxos[3], importCTestData.utxos[1]];
735+
736+
const builder1 = newFactory()
737+
.getImportInCBuilder()
738+
.threshold(importCTestData.threshold)
739+
.fromPubKey(importCTestData.pAddresses)
740+
.decodedUtxos(reorderedUtxos)
741+
.to(importCTestData.to)
742+
.fee(importCTestData.fee)
743+
.context(importCTestData.context);
744+
745+
const unsignedTx = await builder1.build();
746+
unsignedTx.toJson().signatures.length.should.equal(0);
747+
748+
const builder2 = newFactory().from(unsignedTx.toBroadcastFormat());
749+
builder2.sign({ key: importCTestData.privateKeys[2] });
750+
const halfSignedTx = await builder2.build();
751+
halfSignedTx.toJson().signatures.length.should.equal(1);
752+
753+
const builder3 = newFactory().from(halfSignedTx.toBroadcastFormat());
754+
builder3.sign({ key: importCTestData.privateKeys[0] });
755+
const fullSignedTx = await builder3.build();
756+
fullSignedTx.toJson().signatures.length.should.equal(2);
757+
758+
fullSignedTx.toBroadcastFormat().should.be.a.String();
759+
fullSignedTx.id.should.be.a.String();
760+
});
761+
762+
it('should handle 3+ UTXOs with different ordering', async () => {
763+
const mixedUtxos = [importCTestData.utxos[2], importCTestData.utxos[4], importCTestData.utxos[0]];
764+
765+
const txBuilder = newFactory()
766+
.getImportInCBuilder()
767+
.threshold(importCTestData.threshold)
768+
.fromPubKey(importCTestData.pAddresses)
769+
.decodedUtxos(mixedUtxos)
770+
.to(importCTestData.to)
771+
.fee(importCTestData.fee)
772+
.context(importCTestData.context);
773+
774+
txBuilder.sign({ key: importCTestData.privateKeys[2] });
775+
txBuilder.sign({ key: importCTestData.privateKeys[0] });
776+
777+
const tx = await txBuilder.build();
778+
tx.toJson().signatures.length.should.equal(2);
779+
tx.toJson().inputs.length.should.equal(3);
780+
});
781+
782+
it('should handle all 5 UTXOs from test data', async () => {
783+
const allUtxosReversed = [...importCTestData.utxos].reverse();
784+
785+
const txBuilder = newFactory()
786+
.getImportInCBuilder()
787+
.threshold(importCTestData.threshold)
788+
.fromPubKey(importCTestData.pAddresses)
789+
.decodedUtxos(allUtxosReversed)
790+
.to(importCTestData.to)
791+
.fee(importCTestData.fee)
792+
.context(importCTestData.context);
793+
794+
txBuilder.sign({ key: importCTestData.privateKeys[2] });
795+
txBuilder.sign({ key: importCTestData.privateKeys[0] });
796+
797+
const tx = await txBuilder.build();
798+
tx.toJson().signatures.length.should.equal(2);
799+
tx.toJson().inputs.length.should.equal(5);
800+
});
801+
});
802+
803+
describe('ImportInP with multiple UTXOs', () => {
804+
it('should correctly handle multiple UTXOs with different outputidx', async () => {
805+
const multipleUtxos = [
806+
{
807+
...importPTestData.utxos[0],
808+
outputidx: '1',
809+
amount: '25000000',
810+
},
811+
{
812+
...importPTestData.utxos[0],
813+
outputidx: '0',
814+
amount: '25000000',
815+
},
816+
];
817+
818+
const txBuilder = newFactory()
819+
.getImportInPBuilder()
820+
.threshold(importPTestData.threshold)
821+
.locktime(importPTestData.locktime)
822+
.fromPubKey(importPTestData.corethAddresses)
823+
.to(importPTestData.pAddresses)
824+
.externalChainId(importPTestData.sourceChainId)
825+
.feeState(importPTestData.feeState)
826+
.context(importPTestData.context)
827+
.decodedUtxos(multipleUtxos);
828+
829+
txBuilder.sign({ key: importPTestData.privateKeys[2] });
830+
txBuilder.sign({ key: importPTestData.privateKeys[0] });
831+
832+
const tx = await txBuilder.build();
833+
tx.toJson().signatures.length.should.equal(2);
834+
tx.toJson().inputs.length.should.equal(2);
835+
});
836+
837+
it('should correctly sign in parse-sign-parse-sign flow with multiple UTXOs', async () => {
838+
const multipleUtxos = [
839+
{
840+
...importPTestData.utxos[0],
841+
outputidx: '1',
842+
amount: '25000000',
843+
},
844+
{
845+
...importPTestData.utxos[0],
846+
outputidx: '0',
847+
amount: '25000000',
848+
},
849+
];
850+
851+
const builder1 = newFactory()
852+
.getImportInPBuilder()
853+
.threshold(importPTestData.threshold)
854+
.locktime(importPTestData.locktime)
855+
.fromPubKey(importPTestData.corethAddresses)
856+
.to(importPTestData.pAddresses)
857+
.externalChainId(importPTestData.sourceChainId)
858+
.feeState(importPTestData.feeState)
859+
.context(importPTestData.context)
860+
.decodedUtxos(multipleUtxos);
861+
862+
const unsignedTx = await builder1.build();
863+
864+
const builder2 = newFactory().from(unsignedTx.toBroadcastFormat());
865+
builder2.sign({ key: importPTestData.privateKeys[2] });
866+
const halfSignedTx = await builder2.build();
867+
868+
const builder3 = newFactory().from(halfSignedTx.toBroadcastFormat());
869+
builder3.sign({ key: importPTestData.privateKeys[0] });
870+
const fullSignedTx = await builder3.build();
871+
872+
fullSignedTx.toJson().signatures.length.should.equal(2);
873+
fullSignedTx.toBroadcastFormat().should.be.a.String();
874+
});
875+
});
876+
877+
describe('ExportInP with multiple UTXOs', () => {
878+
it('should correctly handle multiple UTXOs that may get reordered', async () => {
879+
const reorderedUtxos = [
880+
{
881+
...exportPTestData.utxos[1],
882+
outputidx: '1',
883+
},
884+
{
885+
...exportPTestData.utxos[1],
886+
outputidx: '0',
887+
},
888+
];
889+
890+
const txBuilder = newFactory()
891+
.getExportInPBuilder()
892+
.threshold(exportPTestData.threshold)
893+
.locktime(exportPTestData.locktime)
894+
.fromPubKey(exportPTestData.pAddresses)
895+
.amount('20000000')
896+
.externalChainId(exportPTestData.sourceChainId)
897+
.feeState(exportPTestData.feeState)
898+
.context(exportPTestData.context)
899+
.decodedUtxos(reorderedUtxos);
900+
901+
txBuilder.sign({ key: exportPTestData.privateKeys[2] });
902+
txBuilder.sign({ key: exportPTestData.privateKeys[0] });
903+
904+
const tx = await txBuilder.build();
905+
tx.toJson().signatures.length.should.equal(2);
906+
});
907+
908+
it('should correctly sign in parse-sign-parse-sign flow with multiple UTXOs', async () => {
909+
const reorderedUtxos = [
910+
{
911+
...exportPTestData.utxos[1],
912+
outputidx: '1',
913+
},
914+
{
915+
...exportPTestData.utxos[1],
916+
outputidx: '0',
917+
},
918+
];
919+
920+
const builder1 = newFactory()
921+
.getExportInPBuilder()
922+
.threshold(exportPTestData.threshold)
923+
.locktime(exportPTestData.locktime)
924+
.fromPubKey(exportPTestData.pAddresses)
925+
.amount('20000000')
926+
.externalChainId(exportPTestData.sourceChainId)
927+
.feeState(exportPTestData.feeState)
928+
.context(exportPTestData.context)
929+
.decodedUtxos(reorderedUtxos);
930+
931+
const unsignedTx = await builder1.build();
932+
933+
const builder2 = newFactory().from(unsignedTx.toBroadcastFormat());
934+
builder2.sign({ key: exportPTestData.privateKeys[2] });
935+
const halfSignedTx = await builder2.build();
936+
937+
const builder3 = newFactory().from(halfSignedTx.toBroadcastFormat());
938+
builder3.sign({ key: exportPTestData.privateKeys[0] });
939+
const fullSignedTx = await builder3.build();
940+
941+
fullSignedTx.toJson().signatures.length.should.equal(2);
942+
fullSignedTx.toBroadcastFormat().should.be.a.String();
943+
});
944+
});
945+
946+
describe('Edge cases for UTXO matching', () => {
947+
it('should match UTXOs by both txid AND outputidx', async () => {
948+
const sameIdUtxos = [
949+
{
950+
...importCTestData.utxos[0],
951+
outputidx: '2',
952+
},
953+
{
954+
...importCTestData.utxos[0],
955+
outputidx: '0',
956+
},
957+
];
958+
959+
const txBuilder = newFactory()
960+
.getImportInCBuilder()
961+
.threshold(importCTestData.threshold)
962+
.fromPubKey(importCTestData.pAddresses)
963+
.decodedUtxos(sameIdUtxos)
964+
.to(importCTestData.to)
965+
.fee(importCTestData.fee)
966+
.context(importCTestData.context);
967+
968+
txBuilder.sign({ key: importCTestData.privateKeys[2] });
969+
txBuilder.sign({ key: importCTestData.privateKeys[0] });
970+
971+
const tx = await txBuilder.build();
972+
tx.toJson().signatures.length.should.equal(2);
973+
tx.toJson().inputs.length.should.equal(2);
974+
});
975+
976+
it('should work correctly when UTXOs are already in sorted order', async () => {
977+
const sortedUtxos = [importCTestData.utxos[0], importCTestData.utxos[1]];
978+
979+
const txBuilder = newFactory()
980+
.getImportInCBuilder()
981+
.threshold(importCTestData.threshold)
982+
.fromPubKey(importCTestData.pAddresses)
983+
.decodedUtxos(sortedUtxos)
984+
.to(importCTestData.to)
985+
.fee(importCTestData.fee)
986+
.context(importCTestData.context);
987+
988+
txBuilder.sign({ key: importCTestData.privateKeys[2] });
989+
txBuilder.sign({ key: importCTestData.privateKeys[0] });
990+
991+
const tx = await txBuilder.build();
992+
tx.toJson().signatures.length.should.equal(2);
993+
});
994+
});
995+
});
697996
});

0 commit comments

Comments
 (0)