diff --git a/helios-internal.d.ts b/helios-internal.d.ts index 8f4f13f3..ecc544b6 100644 --- a/helios-internal.d.ts +++ b/helios-internal.d.ts @@ -863,6 +863,7 @@ declare module "helios" { VALIDITY_RANGE_END_OFFSET?: number | undefined; IGNORE_UNEVALUATED_CONSTANTS?: boolean | undefined; CHECK_CASTS?: boolean | undefined; + MAX_ASSETS_PER_CHANGE_OUTPUT?: number | undefined; }): void; const DEBUG: boolean; const STRICT_BABBAGE: boolean; @@ -873,6 +874,7 @@ declare module "helios" { const VALIDITY_RANGE_END_OFFSET: number; const IGNORE_UNEVALUATED_CONSTANTS: boolean; const CHECK_CASTS: boolean; + const MAX_ASSETS_PER_CHANGE_OUTPUT: undefined; } /** * Read non-byte aligned numbers diff --git a/helios.d.ts b/helios.d.ts index 8e60413f..e3383849 100644 --- a/helios.d.ts +++ b/helios.d.ts @@ -123,6 +123,7 @@ export namespace config { * VALIDITY_RANGE_END_OFFSET?: number * IGNORE_UNEVALUATED_CONSTANTS?: boolean * CHECK_CASTS?: boolean + * MAX_ASSETS_PER_CHANGE_OUTPUT?: number * }} props */ function set(props: { @@ -135,6 +136,7 @@ export namespace config { VALIDITY_RANGE_END_OFFSET?: number | undefined; IGNORE_UNEVALUATED_CONSTANTS?: boolean | undefined; CHECK_CASTS?: boolean | undefined; + MAX_ASSETS_PER_CHANGE_OUTPUT?: number | undefined; }): void; /** * Global debug flag. Currently unused. @@ -207,6 +209,12 @@ export namespace config { * @type {boolean} */ const CHECK_CASTS: boolean; + /** + * Maximum number of assets per change output. Used to break up very large asset outputs into multiple outputs. + * + * Default: `undefined` (no limit). + */ + const MAX_ASSETS_PER_CHANGE_OUTPUT: undefined; } /** * Function that generates a random number between 0 and 1 diff --git a/helios.js b/helios.js index 084e85bd..d9cf04b1 100644 --- a/helios.js +++ b/helios.js @@ -335,6 +335,7 @@ export const config = { * VALIDITY_RANGE_END_OFFSET?: number * IGNORE_UNEVALUATED_CONSTANTS?: boolean * CHECK_CASTS?: boolean + * MAX_ASSETS_PER_CHANGE_OUTPUT?: number * }} props */ set: (props) => { @@ -424,6 +425,13 @@ export const config = { * @type {boolean} */ CHECK_CASTS: false, + + /** + * Maximum number of assets per change output. Used to break up very large asset outputs into multiple outputs. + * + * Default: `undefined` (no limit). + */ + MAX_ASSETS_PER_CHANGE_OUTPUT: undefined, } @@ -44802,9 +44810,34 @@ export class Tx extends CborData { } else { const diff = inputAssets.sub(outputAssets); - const changeOutput = new TxOutput(changeAddress, new Value(0n, diff)); + if (config.MAX_ASSETS_PER_CHANGE_OUTPUT) { + const maxAssetsPerOutput = config.MAX_ASSETS_PER_CHANGE_OUTPUT; + + let changeAssets = new Assets(); + let tokensAdded = 0; + + diff.mintingPolicies.forEach((mph) => { + const tokens = diff.getTokens(mph); + tokens.forEach(([token, quantity], i) => { + changeAssets.addComponent(mph, token, quantity); + tokensAdded += 1; + if (tokensAdded == maxAssetsPerOutput) { + this.#body.addOutput(new TxOutput(changeAddress, new Value(0n, changeAssets))); + changeAssets = new Assets(); + tokensAdded = 0; + } + }); + }); - this.#body.addOutput(changeOutput); + // If we are here and have No assets, they we're done + if (!changeAssets.isZero()) { + this.#body.addOutput(new TxOutput(changeAddress, new Value(0n, changeAssets))); + } + } else { + const changeOutput = new TxOutput(changeAddress, new Value(0n, diff)); + + this.#body.addOutput(changeOutput); + } } } @@ -44956,7 +44989,7 @@ export class Tx extends CborData { nonChangeOutputValue = feeValue.add(nonChangeOutputValue); // this is quite restrictive, but we really don't want to touch UTxOs containing assets just for balancing purposes - const spareAssetUTxOs = spareUtxos.some(utxo => !utxo.value.assets.isZero()); + const spareAssetUTxOs = spareUtxos.filter(utxo => !utxo.value.assets.isZero()); spareUtxos = spareUtxos.filter(utxo => utxo.value.assets.isZero()); // use some spareUtxos if the inputValue doesn't cover the outputs and fees @@ -44966,8 +44999,15 @@ export class Tx extends CborData { let spare = spareUtxos.pop(); if (spare === undefined) { - if (spareAssetUTxOs) { - throw new Error(`UTxOs too fragmented`); + if (spareAssetUTxOs.length > 0) { + spare = spareAssetUTxOs.sort((a, b) => a.output.value.assets.nTokenTypes - b.output.value.assets.nTokenTypes ).pop(); // Should sort so that we get the UTxO with the 'least' number of Assets + + if (!spare){ + throw new Error(`UTxOs too fragmented - or no Spare UTxOs available to fix this mess`); + } + + this.#body.addInput(spare); + this.balanceAssets(changeAddress); } else { throw new Error(`need ${totalOutputValue.lovelace} lovelace, but only have ${inputValue.lovelace}`); } diff --git a/src/config.js b/src/config.js index 456d7ec3..7be76b0c 100644 --- a/src/config.js +++ b/src/config.js @@ -33,6 +33,7 @@ export const config = { * VALIDITY_RANGE_END_OFFSET?: number * IGNORE_UNEVALUATED_CONSTANTS?: boolean * CHECK_CASTS?: boolean + * MAX_ASSETS_PER_CHANGE_OUTPUT?: number * }} props */ set: (props) => { @@ -122,4 +123,11 @@ export const config = { * @type {boolean} */ CHECK_CASTS: false, + + /** + * Maximum number of assets per change output. Used to break up very large asset outputs into multiple outputs. + * + * Default: `undefined` (no limit). + */ + MAX_ASSETS_PER_CHANGE_OUTPUT: undefined, } diff --git a/src/tx-builder.js b/src/tx-builder.js index 0653c220..1d808a59 100644 --- a/src/tx-builder.js +++ b/src/tx-builder.js @@ -867,9 +867,34 @@ export class Tx extends CborData { } else { const diff = inputAssets.sub(outputAssets); - const changeOutput = new TxOutput(changeAddress, new Value(0n, diff)); + if (config.MAX_ASSETS_PER_CHANGE_OUTPUT) { + const maxAssetsPerOutput = config.MAX_ASSETS_PER_CHANGE_OUTPUT; + + let changeAssets = new Assets(); + let tokensAdded = 0; + + diff.mintingPolicies.forEach((mph) => { + const tokens = diff.getTokens(mph); + tokens.forEach(([token, quantity], i) => { + changeAssets.addComponent(mph, token, quantity); + tokensAdded += 1; + if (tokensAdded == maxAssetsPerOutput) { + this.#body.addOutput(new TxOutput(changeAddress, new Value(0n, changeAssets))); + changeAssets = new Assets(); + tokensAdded = 0; + } + }); + }); - this.#body.addOutput(changeOutput); + // If we are here and have No assets, they we're done + if (!changeAssets.isZero()) { + this.#body.addOutput(new TxOutput(changeAddress, new Value(0n, changeAssets))); + } + } else { + const changeOutput = new TxOutput(changeAddress, new Value(0n, diff)); + + this.#body.addOutput(changeOutput); + } } } @@ -1021,7 +1046,7 @@ export class Tx extends CborData { nonChangeOutputValue = feeValue.add(nonChangeOutputValue); // this is quite restrictive, but we really don't want to touch UTxOs containing assets just for balancing purposes - const spareAssetUTxOs = spareUtxos.some(utxo => !utxo.value.assets.isZero()); + const spareAssetUTxOs = spareUtxos.filter(utxo => !utxo.value.assets.isZero()); spareUtxos = spareUtxos.filter(utxo => utxo.value.assets.isZero()); // use some spareUtxos if the inputValue doesn't cover the outputs and fees @@ -1031,8 +1056,15 @@ export class Tx extends CborData { let spare = spareUtxos.pop(); if (spare === undefined) { - if (spareAssetUTxOs) { - throw new Error(`UTxOs too fragmented`); + if (spareAssetUTxOs.length > 0) { + spare = spareAssetUTxOs.sort((a, b) => a.output.value.assets.nTokenTypes - b.output.value.assets.nTokenTypes ).pop(); // Should sort so that we get the UTxO with the 'least' number of Assets + + if (!spare){ + throw new Error(`UTxOs too fragmented - or no Spare UTxOs available to fix this mess`); + } + + this.#body.addInput(spare); + this.balanceAssets(changeAddress); } else { throw new Error(`need ${totalOutputValue.lovelace} lovelace, but only have ${inputValue.lovelace}`); } diff --git a/test/tx-building.test.js b/test/tx-building.test.js index 36edaadd..8b313491 100755 --- a/test/tx-building.test.js +++ b/test/tx-building.test.js @@ -23,7 +23,8 @@ import { assert, bytesToHex, hexToBytes, - textToBytes + textToBytes, + config } from "helios" const networkParams = new NetworkParams(JSON.parse(fs.readFileSync("./network-parameters-preview.json").toString())); @@ -621,6 +622,264 @@ async function sortInputs() { console.log(inputs.map(i => i.txId.hex)); } +async function testAssetSplitOnChangeOutput() { + + const inputClean = new TxInput( + new TxOutputId("a66564e90416a3c3ed89350108799ab122bdbfd098624d0f43f955207ace8eda#1"), + new TxOutput( + new Address("addr_test1wpcwnce7k66ldmduhkqdrgamxmnytekhr2hyp8ncsdcg0aqufrga4"), + new Value(12000000n) + )); + + const input = new TxInput( + new TxOutputId("fed1bb855c77efd1fa209a1b35c447b13d4b09671f7d682263b9f3af1089f58c#1"), + new TxOutput( + new Address("addr_test1qruk42fdnsvvyuha6z23dagxq5966h68ta8d42cdsa6e05muqmq4j86269r4ckhjsvmapapl24fazrtl22yg9sn9pvfsz4vr2h"), + new Value(14172320n, new Assets([[ + "5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7594", [ + ["48656C6C6F20776F726C642031", 1n], + ["48656C6C6F20776F726C642032", 1n], + ["48656C6C6F20776F726C642033", 1n], + ["48656C6C6F20776F726C642034", 1n], + ["48656C6C6F20776F726C642035", 1n], + ["48656C6C6F20776F726C642036", 1n], + ["48656C6C6F20776F726C642037", 1n], + ["48656C6C6F20776F726C642038", 1n], + ["48656C6C6F20776F726C642039", 1n], + ["48656C6C6F20776F726C642040", 1n], + ["48656C6C6F20776F726C642041", 1n], + ["48656C6C6F20776F726C642042", 1n], + ["48656C6C6F20776F726C642043", 1n], + ["48656C6C6F20776F726C642044", 1n], + ["48656C6C6F20776F726C642045", 1n], + ["48656C6C6F20776F726C642046", 1n], + ["48656C6C6F20776F726C642047", 1n], + ["48656C6C6F20776F726C642048", 1n], + ["48656C6C6F20776F726C642049", 1n], + ["48656C6C6F20776F726C642050", 1n], + ["48656C6C6F20776F726C642051", 1n], + ["48656C6C6F20776F726C642052", 1n], + ["48656C6C6F20776F726C642053", 1n], + ["48656C6C6F20776F726C642054", 1n], + ["48656C6C6F20776F726C642055", 1n], + ["48656C6C6F20776F726C642056", 1n], + ["48656C6C6F20776F726C642057", 1n], + ["48656C6C6F20776F726C642058", 1n], + ["48656C6C6F20776F726C642059", 1n], + ["48656C6C6F20776F726C642050", 1n], + ["48656C6C6F20776F726C642061", 1n], + ["48656C6C6F20776F726C642062", 1n], + ["48656C6C6F20776F726C642063", 1n], + ["48656C6C6F20776F726C642064", 1n], + ["48656C6C6F20776F726C642065", 1n], + ["48656C6C6F20776F726C642066", 1n], + ["48656C6C6F20776F726C642067", 1n], + ["48656C6C6F20776F726C642068", 1n], + ["48656C6C6F20776F726C642069", 1n], + ["48656C6C6F20776F726C642060", 1n], + ] + ]])) + ) + ); + + const changeAddress = Address.fromBech32('addr_test1vrk907u2q3tnakfwvwmdl89jhlzy7tfqaqxwzwsch3afw0qqarpt4'); + + let tx = await new Tx() + .addInput(input) + .finalize(networkParams, changeAddress, [inputClean]) + + console.log(tx.body.outputs.length); + let assetsInOutput = tx.body.outputs.map((o) => o.value.assets.getTokenNames(MintingPolicyHash.fromHex('5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7594')).length) + console.log( assetsInOutput.length === 2, assetsInOutput[0] == 39 ); + + config.set({ + MAX_ASSETS_PER_CHANGE_OUTPUT: 5 + }); + + tx = await new Tx() + .addInput(input) + .finalize(networkParams, changeAddress, [inputClean]); + + console.log(tx.body.outputs.length); + assetsInOutput = tx.body.outputs.map((o) => o.value.assets.getTokenNames(MintingPolicyHash.fromHex('5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7594')).length) + console.log( assetsInOutput.length === 9, [0, 1, 2, 3, 4,5,6].map((i) => assetsInOutput[i] === 5 ), assetsInOutput[7] === 4 ); + + config.set({ + MAX_ASSETS_PER_CHANGE_OUTPUT: undefined + }); +} +async function testAssetSplitOnChangeOutputMultiPolicy() { + + const inputClean = new TxInput( + new TxOutputId("a66564e90416a3c3ed89350108799ab122bdbfd098624d0f43f955207ace8eda#1"), + new TxOutput( + new Address("addr_test1wpcwnce7k66ldmduhkqdrgamxmnytekhr2hyp8ncsdcg0aqufrga4"), + new Value(12000000n) + )); + + const input = new TxInput( + new TxOutputId("fed1bb855c77efd1fa209a1b35c447b13d4b09671f7d682263b9f3af1089f58c#1"), + new TxOutput( + new Address("addr_test1qruk42fdnsvvyuha6z23dagxq5966h68ta8d42cdsa6e05muqmq4j86269r4ckhjsvmapapl24fazrtl22yg9sn9pvfsz4vr2h"), + new Value(14172320n, new Assets([[ + "5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7594", [ + ["48656C6C6F20776F726C642031", 1n], + ["48656C6C6F20776F726C642032", 1n], + ["48656C6C6F20776F726C642033", 1n], + ["48656C6C6F20776F726C642034", 1n], + ["48656C6C6F20776F726C642035", 1n], + ["48656C6C6F20776F726C642036", 1n], + ["48656C6C6F20776F726C642037", 1n], + ["48656C6C6F20776F726C642038", 1n], + ["48656C6C6F20776F726C642039", 1n], + ["48656C6C6F20776F726C642040", 1n], + ["48656C6C6F20776F726C642041", 1n], + ["48656C6C6F20776F726C642042", 1n], + ["48656C6C6F20776F726C642043", 1n], + ["48656C6C6F20776F726C642044", 1n], + ["48656C6C6F20776F726C642045", 1n], + ["48656C6C6F20776F726C642046", 1n], + ["48656C6C6F20776F726C642047", 1n], + ["48656C6C6F20776F726C642048", 1n], + ["48656C6C6F20776F726C642049", 1n], + ["48656C6C6F20776F726C642040", 1n], + ["48656C6C6F20776F726C642051", 1n], + ["48656C6C6F20776F726C642052", 1n], + ["48656C6C6F20776F726C642053", 1n], + ["48656C6C6F20776F726C642054", 1n], + ["48656C6C6F20776F726C642055", 1n], + ["48656C6C6F20776F726C642056", 1n], + ["48656C6C6F20776F726C642057", 1n], + ["48656C6C6F20776F726C642058", 1n], + ["48656C6C6F20776F726C642059", 1n], + ["48656C6C6F20776F726C642050", 1n], + ["48656C6C6F20776F726C642061", 1n], + ["48656C6C6F20776F726C642062", 1n], + ["48656C6C6F20776F726C642063", 1n], + ["48656C6C6F20776F726C642064", 1n], + ["48656C6C6F20776F726C642065", 1n], + ["48656C6C6F20776F726C642066", 1n], + ["48656C6C6F20776F726C642067", 1n], + ["48656C6C6F20776F726C642068", 1n], + ["48656C6C6F20776F726C642069", 1n], + ["48656C6C6F20776F726C642060", 1n], + ] + ], [ + "5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7595", [ + ["48656C6C6F20776F726C642031", 1n], + ["48656C6C6F20776F726C642032", 1n], + ["48656C6C6F20776F726C642033", 1n], + ["48656C6C6F20776F726C642034", 1n], + ["48656C6C6F20776F726C642035", 1n], + ["48656C6C6F20776F726C642036", 1n], + ["48656C6C6F20776F726C642037", 1n], + ["48656C6C6F20776F726C642038", 1n], + ["48656C6C6F20776F726C642039", 1n], + ["48656C6C6F20776F726C642040", 1n], + ["48656C6C6F20776F726C642041", 1n], + ["48656C6C6F20776F726C642042", 1n], + ["48656C6C6F20776F726C642043", 1n], + ["48656C6C6F20776F726C642044", 1n], + ["48656C6C6F20776F726C642045", 1n], + ["48656C6C6F20776F726C642046", 1n], + ["48656C6C6F20776F726C642047", 1n], + ["48656C6C6F20776F726C642048", 1n], + ["48656C6C6F20776F726C642049", 1n], + ["48656C6C6F20776F726C642040", 1n], + ["48656C6C6F20776F726C642051", 1n], + ["48656C6C6F20776F726C642052", 1n], + ["48656C6C6F20776F726C642053", 1n], + ["48656C6C6F20776F726C642054", 1n], + ["48656C6C6F20776F726C642055", 1n], + ["48656C6C6F20776F726C642056", 1n], + ["48656C6C6F20776F726C642057", 1n], + ["48656C6C6F20776F726C642058", 1n], + ["48656C6C6F20776F726C642059", 1n], + ["48656C6C6F20776F726C642050", 1n], + ["48656C6C6F20776F726C642061", 1n], + ["48656C6C6F20776F726C642062", 1n], + ["48656C6C6F20776F726C642063", 1n], + ["48656C6C6F20776F726C642064", 1n], + ["48656C6C6F20776F726C642065", 1n], + ["48656C6C6F20776F726C642066", 1n], + ["48656C6C6F20776F726C642067", 1n], + ["48656C6C6F20776F726C642068", 1n], + ["48656C6C6F20776F726C642069", 1n], + ["48656C6C6F20776F726C642060", 1n], + ] + ]])) + ) + ); + + const changeAddress = Address.fromBech32('addr_test1vrk907u2q3tnakfwvwmdl89jhlzy7tfqaqxwzwsch3afw0qqarpt4'); + + let tx = await new Tx() + .addInput(input) + .finalize(networkParams, changeAddress, [inputClean]) + + console.log(tx.body.outputs.length); + let assetsInOutput = tx.body.outputs.map((o) => o.value.assets.getTokenNames(MintingPolicyHash.fromHex('5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7594')).length) + console.log( assetsInOutput.length === 2, assetsInOutput[0] == 39 ); + + config.set({ + MAX_ASSETS_PER_CHANGE_OUTPUT: 5 + }); + + tx = await new Tx() + .addInput(input) + .finalize(networkParams, changeAddress, [inputClean]); + + console.log(tx.body.outputs.length); + let assetsInOutput2 = tx.body.outputs.map((o) => o.value.assets.getTokenNames(MintingPolicyHash.fromHex('5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7594'))) + let assetsInOutput3 = tx.body.outputs.map((o) => o.value.assets.getTokenNames(MintingPolicyHash.fromHex('5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7595'))) + console.log( assetsInOutput2.map((o) => o.map(a => a.hex) )); + console.log( assetsInOutput3.map((o) => o.map(a => a.hex) )); + + config.set({ + MAX_ASSETS_PER_CHANGE_OUTPUT: undefined + }); +} + + +async function testUTxOsTooFragmented() { + + const input1 = new TxInput( + new TxOutputId("a66564e90416a3c3ed89350108799ab122bdbfd098624d0f43f955207ace8eda#1"), + new TxOutput( + new Address("addr_test1wpcwnce7k66ldmduhkqdrgamxmnytekhr2hyp8ncsdcg0aqufrga4"), + new Value(52000000n, new Assets([["5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7500", [["48656C6C6F20776F726C642031", 1n]]]])) + )); + + const input = new TxInput( + new TxOutputId("fed1bb855c77efd1fa209a1b35c447b13d4b09671f7d682263b9f3af1089f58c#1"), + new TxOutput( + new Address("addr_test1qruk42fdnsvvyuha6z23dagxq5966h68ta8d42cdsa6e05muqmq4j86269r4ckhjsvmapapl24fazrtl22yg9sn9pvfsz4vr2h"), + new Value(24172320n, new Assets([[ + "5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7594", [ + ["48656C6C6F20776F726C642031", 1n], + ["48656C6C6F20776F726C642032", 1n], + ] + ]])) + ) + ); + + const changeAddress = Address.fromBech32('addr_test1vrk907u2q3tnakfwvwmdl89jhlzy7tfqaqxwzwsch3afw0qqarpt4'); + const receiveAddress = Address.fromBech32('addr_test1vr8xz7jf77ve6qdjategas0wj2e4wx7szlqgzhy4k3esgng649jwl'); + + let tx = await new Tx() + .addInput(input) + .addOutput(new TxOutput(receiveAddress, new Value(10000000n, + new Assets([[ + "5e2f416b455dc4e5f9a7c7e58919e9a12a1db15e14ed08f8776b7594", [ + ["48656C6C6F20776F726C642031", 1n] + ] + ]])))) + .finalize(networkParams, changeAddress, [input1]); + + console.log(JSON.stringify(tx.dump())); + console.log('8113e38071edbfe02eda73a7fe7b32a1e4ffbe957b1ac0ec1caa05d121a329ea' === bytesToHex(tx.bodyHash)); +} + export default async function main() { await assetsCompare(); @@ -653,4 +912,8 @@ export default async function main() { await testEmulatorPrivateKeyGen(); await sortInputs(); + + await testAssetSplitOnChangeOutput(); + + await testUTxOsTooFragmented(); } \ No newline at end of file