From 05ec950040e76f94a05a4e96add1683449a89b10 Mon Sep 17 00:00:00 2001 From: Vijay-Jagannathan Date: Mon, 23 Oct 2023 16:37:04 +0530 Subject: [PATCH] fix(sdk-coin-ada): add separate UTXOs for each token with 1 ADA Ticket: WIN-736 --- .../src/lib/transactionBuilder.ts | 81 +++++++----- modules/sdk-coin-ada/test/resources/index.ts | 11 +- .../test/unit/transactionBuilder.ts | 115 +++++++++++++----- 3 files changed, 145 insertions(+), 62 deletions(-) diff --git a/modules/sdk-coin-ada/src/lib/transactionBuilder.ts b/modules/sdk-coin-ada/src/lib/transactionBuilder.ts index 486c47e55b..ed62e778a7 100644 --- a/modules/sdk-coin-ada/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-ada/src/lib/transactionBuilder.ts @@ -257,34 +257,59 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { }); } - const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); - outputs.add(changeOutput); - } - - // support for multi-asset consolidation - if (this._multiAssets !== undefined) { - this._multiAssets.forEach((asset) => { - let txOutputBuilder = CardanoWasm.TransactionOutputBuilder.new(); - const toAddress = CardanoWasm.Address.from_bech32(this._transactionOutputs[0].address); - txOutputBuilder = txOutputBuilder.with_address(toAddress); - let txOutputAmountBuilder = txOutputBuilder.next(); - const assetName = CardanoWasm.AssetName.new(Buffer.from(asset.asset_name, 'hex')); - const policyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(asset.policy_id, 'hex')); - const multiAsset = CardanoWasm.MultiAsset.new(); - const assets = CardanoWasm.Assets.new(); - assets.insert(assetName, CardanoWasm.BigNum.from_str(asset.quantity)); - multiAsset.insert(policyId, assets); - - // coin value should be zero since this output is related to token - const coinValue = '0'; - txOutputAmountBuilder = txOutputAmountBuilder.with_coin_and_asset( - CardanoWasm.BigNum.from_str(coinValue), - multiAsset - ); - - const txOutput = txOutputAmountBuilder.build(); - outputs.add(txOutput); - }); + // If totalAmountToSend is 0, its consolidation + if (totalAmountToSend.to_str() == '0') { + // support for multi-asset consolidation + if (this._multiAssets !== undefined) { + const totalNumberOfAssets = CardanoWasm.BigNum.from_str(this._multiAssets.length.toString()); + const minAmountNeededForOneAssetOutput = CardanoWasm.BigNum.from_str('1000000'); + const minAmountNeededForTotalAssetOutputs = minAmountNeededForOneAssetOutput.checked_mul(totalNumberOfAssets); + + if (!change.less_than(minAmountNeededForTotalAssetOutputs)) { + this._multiAssets.forEach((asset) => { + let txOutputBuilder = CardanoWasm.TransactionOutputBuilder.new(); + // changeAddress is the root address, which is where we want the tokens assets to be sent to + const toAddress = CardanoWasm.Address.from_bech32(this._changeAddress); + txOutputBuilder = txOutputBuilder.with_address(toAddress); + let txOutputAmountBuilder = txOutputBuilder.next(); + const assetName = CardanoWasm.AssetName.new(Buffer.from(asset.asset_name, 'hex')); + const policyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(asset.policy_id, 'hex')); + const multiAsset = CardanoWasm.MultiAsset.new(); + const assets = CardanoWasm.Assets.new(); + assets.insert(assetName, CardanoWasm.BigNum.from_str(asset.quantity)); + multiAsset.insert(policyId, assets); + + txOutputAmountBuilder = txOutputAmountBuilder.with_coin_and_asset( + minAmountNeededForOneAssetOutput, + multiAsset + ); + + const txOutput = txOutputAmountBuilder.build(); + outputs.add(txOutput); + }); + + // finally send the remaining ADA in its own output + const remainingOutputAmount = change.checked_sub(minAmountNeededForTotalAssetOutputs); + const changeOutput = CardanoWasm.TransactionOutput.new( + changeAddress, + CardanoWasm.Value.new(remainingOutputAmount) + ); + outputs.add(changeOutput); + } else { + throw new BuildTransactionError( + 'Insufficient funds: need a minimum of 1 ADA per output to construct token consolidation' + ); + } + } else { + // If there are no tokens to consolidate, you only have 1 output which is ADA alone + const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); + outputs.add(changeOutput); + } + } else { + // If this isn't a consolidate request, whatever change that needs to be sent back to the rootaddress is added as a separate output here + const changeOutput = CardanoWasm.TransactionOutput.new(changeAddress, CardanoWasm.Value.new(change)); + outputs.add(changeOutput); + } } const txRaw = CardanoWasm.TransactionBody.new_tx_body(inputs, outputs, this._fee); diff --git a/modules/sdk-coin-ada/test/resources/index.ts b/modules/sdk-coin-ada/test/resources/index.ts index 058f9d284b..3d8cf1b00c 100644 --- a/modules/sdk-coin-ada/test/resources/index.ts +++ b/modules/sdk-coin-ada/test/resources/index.ts @@ -120,15 +120,18 @@ export const rawTx = { unsignedTx2: '84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba210101828258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f1a00775f1182581d60ce3edb7ad0f096553830096453e97919efc0962ed9d09a3a2c82c5e11a00c6ffe9021a00028d5d031a2faf08000480a10080f5f6', unsignedTx3: - '84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba210101838258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f1a00775f1182581d60ce3edb7ad0f096553830096453e97919efc0962ed9d09a3a2c82c5e11a00c6ffe98258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f8200a1581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3fa144534e454b1a005b8d80021a00028d5d031a2faf08000480a10080f5f6', + '84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba210101828258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f821a000f4240a1581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3fa144534e454b1a005b8d808258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f1a011f63bf021a00028701031a2faf08000480a10080f5f6', unsignedTx4: - '84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba210101848258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f1a00775f1182581d60ce3edb7ad0f096553830096453e97919efc0962ed9d09a3a2c82c5e11a00c6ffe98258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f8200a1581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3fa144534e454b1a005b8d808258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f8200a1581c1f7a58a1aa1e6b047a42109ade331ce26c9c2cce027d043ff264fb1fa146425249434b531a004c4b40021a00028d5d031a2faf08000480a10080f5f6', + '84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba210101838258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f821a000f4240a1581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3fa144534e454b1a005b8d808258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f821a000f4240a1581c1f7a58a1aa1e6b047a42109ade331ce26c9c2cce027d043ff264fb1fa146425249434b531a004c4b408258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f1a0110217f021a00028701031a2faf08000480a10080f5f6', + unsignedTx5: + '84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba210101818258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f1a012ea5ff021a00028701031a2faf08000480a10080f5f6', signedTx2: '84a500818258203677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba210101828258390027360563c4479c6aa054cb2bd3ca9e394731ab59f8c45511ec8ba851aee1672f3f7fedc48feca58979967030dc8edc340c551b49d067638f1a00775f1182581d60ce3edb7ad0f096553830096453e97919efc0962ed9d09a3a2c82c5e11a00c6ffe9021a00028d5d031a2faf08000480a10081825820a5cdaab58f8c0cb82897b855b8a2315fba8061122072fcae1e0790641d9f9e675840765ee4f52bd3a2ae85a7d921ab2068698d70cf4ff291af2cf56f0858548d9e30c37b4f3e44c39ca885f18eb3c3648cbcaf30d1acb650d0a9c7d377591a214404f5f6', txHash: '0933ee2669649595c39150cdad64418303744352e1d315aa2f060f291980639a', txHash2: '1088141814e014e07d5e6c3ffb6c877a5c6ee2210694570e01bfc9a6ee6eedf5', - txHash3: 'e00f11e664ebb12759c413eeeb00e3bf42a6e53849316c850ddea5dbbab10d32', - txHash4: '9913769cf5df070f7c561d3bfca81b2507110f9505ef787d02aeed5280f5e44c', + txHash3: '5c45b86ce5df2f4aa20aa38ffb8428b9cc09ad8b6de0b30e81dea6ef786723e7', + txHash4: '1abec9dd3798daf69245fa99bd29729de868113815641298b86735b29435e4a3', + txHash5: '65c0b30500a9283fd787bdcd98c2a44cbeaeb3c3768e36f555adb257c00c9299', outputAddress1: { address: 'addr_test1qqnnvptrc3rec64q2n9jh572ncu5wvdtt8uvg4g3aj96s5dwu9nj70mlahzglm9939uevupsmj8dcdqv25d5n5r8vw8sn7prey', diff --git a/modules/sdk-coin-ada/test/unit/transactionBuilder.ts b/modules/sdk-coin-ada/test/unit/transactionBuilder.ts index e85596d392..3f3e5c21ea 100644 --- a/modules/sdk-coin-ada/test/unit/transactionBuilder.ts +++ b/modules/sdk-coin-ada/test/unit/transactionBuilder.ts @@ -39,17 +39,41 @@ describe('ADA Transaction Builder', async () => { should.equal(txBroadcast, testData.rawTx.unsignedTx2); }); - it('build a tx with single asset', async () => { + it('should build a consolidate tx with no asset', async () => { const txBuilder = factory.getTransferBuilder(); txBuilder.input({ transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', transaction_index: 1, }); - const outputAmount = 7823121; - txBuilder.output({ - address: testData.rawTx.outputAddress1.address, - amount: outputAmount.toString(), + + const outputAmount = 0; + const totalInput = 20000000; + txBuilder.changeAddress(testData.rawTx.outputAddress1.address, totalInput.toString()); + txBuilder.ttl(800000000); + const tx = (await txBuilder.build()) as Transaction; + should.equal(tx.type, TransactionType.Send); + const txData = tx.toJson(); + txData.witnesses.length.should.equal(0); + txData.certs.length.should.equal(0); + txData.withdrawals.length.should.equal(0); + txData.outputs.length.should.equal(1); + txData.outputs[0].address.should.equal(testData.rawTx.outputAddress1.address); + const fee = tx.getFee; + txData.outputs[0].amount.should.equal((totalInput - outputAmount - Number(fee)).toString()); + fee.should.equal('165633'); + txData.id.should.equal(testData.rawTx.txHash5); + const txBroadcast = tx.toBroadcastFormat(); + should.equal(txBroadcast, testData.rawTx.unsignedTx5); + }); + + it('should build a consolidate tx with single asset', async () => { + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, }); + + const outputAmount = 0; const policyId = '279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f'; const assetName = '534e454b'; const quantity = '6000000'; @@ -58,8 +82,9 @@ describe('ADA Transaction Builder', async () => { asset_name: assetName, quantity: quantity, }); - const totalInput = 21032023; - txBuilder.changeAddress(testData.rawTx.outputAddress2.address, totalInput.toString()); + const minAmountForSingleAsset = 1000000; + const totalInput = 20000000; + txBuilder.changeAddress(testData.rawTx.outputAddress1.address, totalInput.toString()); txBuilder.ttl(800000000); const tx = (await txBuilder.build()) as Transaction; should.equal(tx.type, TransactionType.Send); @@ -67,39 +92,38 @@ describe('ADA Transaction Builder', async () => { txData.witnesses.length.should.equal(0); txData.certs.length.should.equal(0); txData.withdrawals.length.should.equal(0); - txData.outputs.length.should.equal(3); + txData.outputs.length.should.equal(2); txData.outputs[0].address.should.equal(testData.rawTx.outputAddress1.address); - txData.outputs[1].address.should.equal(testData.rawTx.outputAddress2.address); - txData.outputs[2].address.should.equal(testData.rawTx.outputAddress1.address); + txData.outputs[1].address.should.equal(testData.rawTx.outputAddress1.address); // token assertion const expectedAssetName = CardanoWasm.AssetName.new(Buffer.from(assetName, 'hex')); const expectedPolicyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(policyId, 'hex')); - txData.outputs[2].should.have.property('multiAssets'); - (txData.outputs[2].multiAssets as CardanoWasm.MultiAsset) + txData.outputs[0].amount.should.equal(minAmountForSingleAsset.toString()); + txData.outputs[0].should.have.property('multiAssets'); + (txData.outputs[0].multiAssets as CardanoWasm.MultiAsset) .get_asset(expectedPolicyId, expectedAssetName) .to_str() .should.equal(quantity); const fee = tx.getFee; - txData.outputs[1].amount.should.equal((totalInput - outputAmount - Number(fee)).toString()); - fee.should.equal('167261'); + txData.outputs[1].amount.should.equal( + (totalInput - minAmountForSingleAsset - outputAmount - Number(fee)).toString() + ); + fee.should.equal('165633'); txData.id.should.equal(testData.rawTx.txHash3); const txBroadcast = tx.toBroadcastFormat(); should.equal(txBroadcast, testData.rawTx.unsignedTx3); }); - it('build a tx with multiple assets', async () => { + it('should build a consolidate tx with multiple assets', async () => { const txBuilder = factory.getTransferBuilder(); txBuilder.input({ transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', transaction_index: 1, }); - const outputAmount = 7823121; - txBuilder.output({ - address: testData.rawTx.outputAddress1.address, - amount: outputAmount.toString(), - }); + + const outputAmount = 0; const asset1_policyId = '279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f'; const asset1_assetName = '534e454b'; const asset1_quantity = '6000000'; @@ -118,8 +142,9 @@ describe('ADA Transaction Builder', async () => { quantity: asset2_quantity, }); - const totalInput = 21032023; - txBuilder.changeAddress(testData.rawTx.outputAddress2.address, totalInput.toString()); + const totalInput = 20000000; + const minAmountForSingleAsset = 1000000; + txBuilder.changeAddress(testData.rawTx.outputAddress1.address, totalInput.toString()); txBuilder.ttl(800000000); const tx = (await txBuilder.build()) as Transaction; should.equal(tx.type, TransactionType.Send); @@ -127,35 +152,65 @@ describe('ADA Transaction Builder', async () => { txData.witnesses.length.should.equal(0); txData.certs.length.should.equal(0); txData.withdrawals.length.should.equal(0); - txData.outputs.length.should.equal(4); + txData.outputs.length.should.equal(3); txData.outputs[0].address.should.equal(testData.rawTx.outputAddress1.address); - txData.outputs[1].address.should.equal(testData.rawTx.outputAddress2.address); + txData.outputs[1].address.should.equal(testData.rawTx.outputAddress1.address); txData.outputs[2].address.should.equal(testData.rawTx.outputAddress1.address); // token assertion const asset1_expectedAssetName = CardanoWasm.AssetName.new(Buffer.from(asset1_assetName, 'hex')); const asset1_expectedPolicyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(asset1_policyId, 'hex')); - txData.outputs[2].should.have.property('multiAssets'); - (txData.outputs[2].multiAssets as CardanoWasm.MultiAsset) + txData.outputs[0].amount.should.equal(minAmountForSingleAsset.toString()); + txData.outputs[0].should.have.property('multiAssets'); + (txData.outputs[0].multiAssets as CardanoWasm.MultiAsset) .get_asset(asset1_expectedPolicyId, asset1_expectedAssetName) .to_str() .should.equal(asset1_quantity); const asset2_expectedAssetName = CardanoWasm.AssetName.new(Buffer.from(asset2_assetName, 'hex')); const asset2_expectedPolicyId = CardanoWasm.ScriptHash.from_bytes(Buffer.from(asset2_policyId, 'hex')); - txData.outputs[3].should.have.property('multiAssets'); - (txData.outputs[3].multiAssets as CardanoWasm.MultiAsset) + txData.outputs[1].amount.should.equal(minAmountForSingleAsset.toString()); + txData.outputs[1].should.have.property('multiAssets'); + (txData.outputs[1].multiAssets as CardanoWasm.MultiAsset) .get_asset(asset2_expectedPolicyId, asset2_expectedAssetName) .to_str() .should.equal(asset2_quantity); const fee = tx.getFee; - txData.outputs[1].amount.should.equal((totalInput - outputAmount - Number(fee)).toString()); - fee.should.equal('167261'); + txData.outputs[2].amount.should.equal( + (totalInput - minAmountForSingleAsset * 2 - outputAmount - Number(fee)).toString() + ); + fee.should.equal('165633'); txData.id.should.equal(testData.rawTx.txHash4); const txBroadcast = tx.toBroadcastFormat(); should.equal(txBroadcast, testData.rawTx.unsignedTx4); }); + it('should fail to build a consolidate tx with single asset and insufficient minimum ADA', async () => { + const txBuilder = factory.getTransferBuilder(); + txBuilder.input({ + transaction_id: '3677e75c7ba699bfdc6cd57d42f246f86f63aefd76025006ac78313fad2bba21', + transaction_index: 1, + }); + + const policyId = '279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f'; + const assetName = '534e454b'; + const quantity = '6000000'; + txBuilder.assets({ + policy_id: policyId, + asset_name: assetName, + quantity: quantity, + }); + + // change = input amt - fee - output amt => change will be less than 1000000 + // even if one output has less than 1 ADA, tx will fail + const totalInput = 1000000; + txBuilder.changeAddress(testData.rawTx.outputAddress1.address, totalInput.toString()); + txBuilder.ttl(800000000); + await txBuilder + .build() + .should.rejectedWith('Insufficient funds: need a minimum of 1 ADA per output to construct token consolidation'); + }); + it('build and sign a transfer tx', async () => { const txBuilder = factory.getTransferBuilder(); txBuilder.input({