diff --git a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts index ce30b4c73c..8d32ed4d76 100644 --- a/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ExportInPTxBuilder.ts @@ -179,7 +179,28 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { const flareUnsignedTx = exportTx as UnsignedTx; const innerTx = flareUnsignedTx.getTx() as pvmSerial.ExportTx; - const utxosWithIndex = innerTx.baseTx.inputs.map((input) => { + const changeOutputs = innerTx.baseTx.outputs; + let correctedExportTx: pvmSerial.ExportTx = innerTx; + + if (changeOutputs.length > 0) { + const correctedChangeOutputs = changeOutputs.map((output) => { + const transferOut = output.output as TransferOutput; + const originalOwners = transferOut.outputOwners; + + const assetIdStr = utils.flareIdString(Buffer.from(output.assetId.toBytes()).toString('hex')).toString(); + return TransferableOutput.fromNative( + assetIdStr, + transferOut.amount(), + originalOwners.addrs.map((addr) => Buffer.from(addr.toBytes())), + this.transaction._locktime, + this.transaction._threshold + ); + }); + + correctedExportTx = this.createCorrectedExportTx(innerTx, correctedChangeOutputs); + } + + const utxosWithIndex = correctedExportTx.baseTx.inputs.map((input) => { const inputTxid = utils.cb58Encode(Buffer.from(input.utxoID.txID.toBytes())); const inputOutputIdx = input.utxoID.outputIdx.value().toString(); @@ -213,11 +234,46 @@ export class ExportInPTxBuilder extends AtomicTransactionBuilder { this.createAddressMapForUtxoWithSigIndices(utxo, utxo.threshold, utxo.actualSigIndices) ); - const fixedUnsignedTx = new UnsignedTx(innerTx, [], new FlareUtils.AddressMaps(addressMaps), txCredentials); + const fixedUnsignedTx = new UnsignedTx( + correctedExportTx, + [], + new FlareUtils.AddressMaps(addressMaps), + txCredentials + ); this.transaction.setTransaction(fixedUnsignedTx); } + /** + * Create a corrected ExportTx with the given change outputs. + * This is necessary because FlareJS's newExportTx doesn't support setting + * the threshold and locktime for change outputs. + * + * FlareJS declares baseTx.outputs as readonly, so we use Object.defineProperty + * to override the property with the corrected outputs. This is a workaround until + * FlareJS adds proper support for change output thresholds. + * + * @param originalTx - The original ExportTx + * @param correctedOutputs - The corrected change outputs with proper threshold + * @returns A new ExportTx with the corrected change outputs + */ + private createCorrectedExportTx( + originalTx: pvmSerial.ExportTx, + correctedOutputs: TransferableOutput[] + ): pvmSerial.ExportTx { + // FlareJS declares baseTx.outputs as `public readonly outputs: readonly TransferableOutput[]` + // We use Object.defineProperty to override the readonly property with our corrected outputs. + // This is necessary because FlareJS's newExportTx doesn't support change output threshold/locktime. + Object.defineProperty(originalTx.baseTx, 'outputs', { + value: correctedOutputs, + writable: false, + enumerable: true, + configurable: true, + }); + + return originalTx; + } + /** * Recover UTXOs from inputs. * Uses output addresses as proxy for UTXO addresses. diff --git a/modules/sdk-coin-flrp/test/resources/transactionData/exportInP.ts b/modules/sdk-coin-flrp/test/resources/transactionData/exportInP.ts index 2db5795cb9..ebf3a9c414 100644 --- a/modules/sdk-coin-flrp/test/resources/transactionData/exportInP.ts +++ b/modules/sdk-coin-flrp/test/resources/transactionData/exportInP.ts @@ -1,14 +1,13 @@ // Test data for export with single UTXO -// Transaction ID: 2R4iE6sX6BtAeTNrVdzifczs7qRGQw3yiaFTXaw9j9A4R6D5FW export const EXPORT_IN_P = { - txhash: '2R4iE6sX6BtAeTNrVdzifczs7qRGQw3yiaFTXaw9j9A4R6D5FW', + txhash: '2HN4sSQeyS1T8ztWdsVv4Cyj3D8zJxGTMaSZeqWVmBfJEiYsiQ', // Unsigned tx from script (with empty signatures + credential structure) unsignedHex: - '0x0000000000120000007200000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000000daba24000000000000000000000001000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f9100000002b489f3c616329c3d56a29c24702c348522e87e1ad0e23f02fbb405be599fbae30000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500000000015d35f30000000100000000e65421a9e3307ed1f644e6e44855f94e01c35ea2bce2cdd9005a77e48decbd5c0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002e7b2b80000000200000000000000010000000078db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000003473bc0000000000000000000000002000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f910000000200000009000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007e918a5e8083ae4c9f2f0ed77055c24bf3665001000000090000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007e918a5e8083ae4c9f2f0ed77055c24bf36650012845ffc4', + '0x0000000000120000007200000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000000daba24000000000000000000000002000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f9100000002b489f3c616329c3d56a29c24702c348522e87e1ad0e23f02fbb405be599fbae30000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500000000015d35f30000000100000000e65421a9e3307ed1f644e6e44855f94e01c35ea2bce2cdd9005a77e48decbd5c0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002e7b2b80000000200000000000000010000000078db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000003473bc0000000000000000000000002000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f910000000200000009000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007e918a5e8083ae4c9f2f0ed77055c24bf3665001000000090000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007e918a5e8083ae4c9f2f0ed77055c24bf36650016aec5964', halfSigntxHex: - '0x0000000000120000007200000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000000daba24000000000000000000000001000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f9100000002b489f3c616329c3d56a29c24702c348522e87e1ad0e23f02fbb405be599fbae30000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500000000015d35f30000000100000000e65421a9e3307ed1f644e6e44855f94e01c35ea2bce2cdd9005a77e48decbd5c0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002e7b2b80000000200000000000000010000000078db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000003473bc0000000000000000000000002000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f9100000002000000090000000184918d7e399e192bf14262c9f9622feb13f412e987f6c411b90215175e4ef4d61c629593188d703e53579a88cef6973414b7c2bef10c582ea883b1767be2622a00000000090000000284918d7e399e192bf14262c9f9622feb13f412e987f6c411b90215175e4ef4d61c629593188d703e53579a88cef6973414b7c2bef10c582ea883b1767be2622a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007e918a5e8083ae4c9f2f0ed77055c24bf3665001f945a1ff', + '0x0000000000120000007200000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000000daba24000000000000000000000002000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f9100000002b489f3c616329c3d56a29c24702c348522e87e1ad0e23f02fbb405be599fbae30000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500000000015d35f30000000100000000e65421a9e3307ed1f644e6e44855f94e01c35ea2bce2cdd9005a77e48decbd5c0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002e7b2b80000000200000000000000010000000078db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000003473bc0000000000000000000000002000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f91000000020000000900000001ef504cec2443f9f68078976afa43eb518d7c6b8455389a743fe25fdde1b8e04f5bc94002efbdbd4a1feb3f5739f944970484336ae51d6aff3802493b0014e85c000000000900000002ef504cec2443f9f68078976afa43eb518d7c6b8455389a743fe25fdde1b8e04f5bc94002efbdbd4a1feb3f5739f944970484336ae51d6aff3802493b0014e85c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007e918a5e8083ae4c9f2f0ed77055c24bf3665001329c4a92', fullSigntxHex: - '0x0000000000120000007200000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000000daba24000000000000000000000001000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f9100000002b489f3c616329c3d56a29c24702c348522e87e1ad0e23f02fbb405be599fbae30000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500000000015d35f30000000100000000e65421a9e3307ed1f644e6e44855f94e01c35ea2bce2cdd9005a77e48decbd5c0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002e7b2b80000000200000000000000010000000078db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000003473bc0000000000000000000000002000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f9100000002000000090000000184918d7e399e192bf14262c9f9622feb13f412e987f6c411b90215175e4ef4d61c629593188d703e53579a88cef6973414b7c2bef10c582ea883b1767be2622a00000000090000000284918d7e399e192bf14262c9f9622feb13f412e987f6c411b90215175e4ef4d61c629593188d703e53579a88cef6973414b7c2bef10c582ea883b1767be2622a0067902f8f061a628182a3ec1d768b100a3d13fcc01966a993c3300bf3cb5d3dc32870442f83c67741d22f5e7a1168f5ef7f22f26d7b62a183528fcbfd0fdede9300f1e3d1ac', + '0x0000000000120000007200000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000000daba24000000000000000000000002000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f9100000002b489f3c616329c3d56a29c24702c348522e87e1ad0e23f02fbb405be599fbae30000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500000000015d35f30000000100000000e65421a9e3307ed1f644e6e44855f94e01c35ea2bce2cdd9005a77e48decbd5c0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002e7b2b80000000200000000000000010000000078db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da55524790000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000003473bc0000000000000000000000002000000033329be7d01cd3ebaae6654d7327dd9f17a2e15817e918a5e8083ae4c9f2f0ed77055c24bf3665001c7324437c96c7c8a6a152da2385c1db5c3ab1f91000000020000000900000001ef504cec2443f9f68078976afa43eb518d7c6b8455389a743fe25fdde1b8e04f5bc94002efbdbd4a1feb3f5739f944970484336ae51d6aff3802493b0014e85c000000000900000002ef504cec2443f9f68078976afa43eb518d7c6b8455389a743fe25fdde1b8e04f5bc94002efbdbd4a1feb3f5739f944970484336ae51d6aff3802493b0014e85c000fd603ba6d1c63b84c0bfb50e8a2eef0a745379fbf687a09d15f2b411907eb886d725ff52060e7863258c5abe506a690446923ec9f00b97487c2df781aea88ab0181fe5c0a', amount: '55000000', // 0.055 (0.05 FLR + 0.005 FLR fee) pAddresses: [ 'P-costwo106gc5h5qswhye8e0pmthq4wzf0ekv5qppsrvpu', diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index 9790587b90..17484c8c1f 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -4,6 +4,7 @@ import { EXPORT_IN_P as testData } from '../../resources/transactionData/exportI import { TransactionBuilderFactory } from '../../../src/lib'; import { coins } from '@bitgo/statics'; import signFlowTest from './signFlowTestSuit'; +import { pvmSerial, UnsignedTx, TransferOutput } from '@flarenetwork/flarejs'; describe('Flrp Export In P Tx Builder', () => { const coinConfig = coins.get('tflrp'); @@ -364,4 +365,141 @@ describe('Flrp Export In P Tx Builder', () => { fullSignedTx.toBroadcastFormat().should.be.a.String(); }); }); + + describe('Change output threshold fix', () => { + /** + * This test suite verifies the fix for the change output threshold bug. + * + * The issue: FlareJS's pvm.e.newExportTx() defaults change outputs to threshold=1, + * but for multisig wallets we need threshold=2 to maintain proper security. + * + * The fix: After building the transaction, we correct the change outputs to use + * the wallet's threshold (typically 2 for multisig). + */ + + it('should create change output with threshold=2 for multisig wallets', async () => { + 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(testData.utxos); + + const tx = await txBuilder.build(); + + const flareTransaction = (tx as any)._flareTransaction as UnsignedTx; + const innerTx = flareTransaction.getTx() as pvmSerial.ExportTx; + const changeOutputs = innerTx.baseTx.outputs; + + changeOutputs.length.should.be.greaterThan(0); + + changeOutputs.forEach((output, index) => { + const transferOut = output.output as TransferOutput; + const threshold = transferOut.outputOwners.threshold.value(); + + threshold.should.equal( + testData.threshold, + `Change output ${index} should have threshold=${testData.threshold}, but has threshold=${threshold}` + ); + }); + }); + + it('should create change output with correct locktime for multisig wallets', async () => { + 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(testData.utxos); + + const tx = await txBuilder.build(); + + const flareTransaction = (tx as any)._flareTransaction as UnsignedTx; + const innerTx = flareTransaction.getTx() as pvmSerial.ExportTx; + const changeOutputs = innerTx.baseTx.outputs; + + changeOutputs.length.should.be.greaterThan(0); + + changeOutputs.forEach((output, index) => { + const transferOut = output.output as TransferOutput; + const locktime = transferOut.outputOwners.locktime.value(); + + locktime.should.equal( + BigInt(testData.locktime), + `Change output ${index} should have locktime=${testData.locktime}, but has locktime=${locktime}` + ); + }); + }); + + it('should maintain threshold=2 after parsing and rebuilding', async () => { + 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(testData.utxos); + + const tx = await txBuilder.build(); + const txHex = tx.toBroadcastFormat(); + + const parsedTxBuilder = factory.from(txHex); + const parsedTx = await parsedTxBuilder.build(); + + const flareTransaction = (parsedTx as any)._flareTransaction as UnsignedTx; + const innerTx = flareTransaction.getTx() as pvmSerial.ExportTx; + const changeOutputs = innerTx.baseTx.outputs; + + changeOutputs.length.should.be.greaterThan(0); + + changeOutputs.forEach((output, index) => { + const transferOut = output.output as TransferOutput; + const threshold = transferOut.outputOwners.threshold.value(); + + threshold.should.equal( + testData.threshold, + `After parsing, change output ${index} should have threshold=${testData.threshold}, but has threshold=${threshold}` + ); + }); + }); + + it('should have change output addresses matching wallet addresses', async () => { + 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(testData.utxos); + + const tx = await txBuilder.build(); + + const flareTransaction = (tx as any)._flareTransaction as UnsignedTx; + const innerTx = flareTransaction.getTx() as pvmSerial.ExportTx; + const changeOutputs = innerTx.baseTx.outputs; + + changeOutputs.length.should.be.greaterThan(0); + + changeOutputs.forEach((output) => { + const transferOut = output.output as TransferOutput; + const addresses = transferOut.outputOwners.addrs; + + addresses.length.should.equal(3, 'Change output should have 3 addresses for multisig wallet'); + }); + }); + }); });