diff --git a/.changeset/tidy-impalas-smile.md b/.changeset/tidy-impalas-smile.md new file mode 100644 index 00000000..6700ec44 --- /dev/null +++ b/.changeset/tidy-impalas-smile.md @@ -0,0 +1,5 @@ +--- +"@lucid-evolution/lucid": patch +--- + +inlude tiny change in fee diff --git a/packages/lucid/src/tx-builder/internal/CompleteTxBuilder.ts b/packages/lucid/src/tx-builder/internal/CompleteTxBuilder.ts index 36581c1d..6f1c4de6 100644 --- a/packages/lucid/src/tx-builder/internal/CompleteTxBuilder.ts +++ b/packages/lucid/src/tx-builder/internal/CompleteTxBuilder.ts @@ -70,6 +70,12 @@ export type CompleteOptions = { */ canonical?: boolean; + /** + * Include tiny change (lovelace) in transaction fee if it's too small + * @default false + */ + includeTinyChangeInFee?: boolean; + /** * Preset UTXOs from the wallet to include in coin selection. * If not provided, wallet UTXOs will be fetched by the provider. @@ -81,6 +87,11 @@ export type CompleteOptions = { presetWalletInputs?: UTxO[]; }; +type CoinSelectionResult = { + selected: UTxO[]; + burnable: Assets; +}; + export const completeTxError = (cause: unknown) => new TxBuilderError({ cause: `{ Complete: ${cause} }` }); @@ -105,6 +116,7 @@ export const complete = ( localUPLCEval = true, setCollateral = 5_000_000n, canonical = false, + includeTinyChangeInFee = false, presetWalletInputs = [], } = options; @@ -138,6 +150,7 @@ export const complete = ( changeAddress, coinSelection, localUPLCEval, + includeTinyChangeInFee, false, ); // Second round of coin selection by including script execution costs in fee estimation. @@ -150,6 +163,7 @@ export const complete = ( changeAddress, coinSelection, localUPLCEval, + includeTinyChangeInFee, true, ); @@ -200,6 +214,7 @@ export const selectionAndEvaluation = ( changeAddress: string, coinSelection: boolean, localUPLCEval: boolean, + includeTinyChangeInFee: boolean, script_calculation: boolean, ): Effect.Effect => Effect.gen(function* () { @@ -208,14 +223,22 @@ export const selectionAndEvaluation = ( config.collectedInputs, ); - const inputsToAdd = + const { selected: inputsToAdd, burnable } = coinSelection !== false - ? yield* doCoinSelection(config, availableInputs, script_calculation) - : []; + ? yield* doCoinSelection( + config, + availableInputs, + script_calculation, + includeTinyChangeInFee, + ) + : { selected: [], burnable: {} }; // Skip UPLC evaluation for the second time if no new inputs are added - if (_Array.isEmptyArray(inputsToAdd) && script_calculation) return; let estimatedFee = 0n; + if (_Array.isEmptyArray(inputsToAdd)) { + if (script_calculation) return; + estimatedFee += burnable.lovelace; + } if (_Array.isNonEmptyArray(inputsToAdd)) { for (const utxo of inputsToAdd) { const input = CML.SingleInputBuilder.from_transaction_unspent_output( @@ -466,14 +489,14 @@ const findCollateral = ( `Your wallet does not have enough funds to cover the required ${setCollateral} Lovelace collateral. Or it contains UTxOs with reference scripts; which are excluded from collateral selection.`, ); - const selected = yield* recursive( + const { selected } = yield* recursive( sortUTxOs(inputs), collateralLovelace, coinsPerUtxoByte, undefined, + false, error, ); - if (selected.length > 3) yield* completeTxError( `Selected ${selected.length} inputs as collateral, but max collateral inputs is 3 to cover the ${setCollateral} Lovelace collateral ${stringify(selected)}`, @@ -485,7 +508,8 @@ const doCoinSelection = ( config: TxBuilder.TxBuilderConfig, availableInputs: UTxO[], script_calculation: boolean, -): Effect.Effect => + includeTinyChangeInFee: boolean, +): Effect.Effect<{ selected: UTxO[]; burnable: Assets }, TxBuilderError> => Effect.gen(function* () { // NOTE: This is a fee estimation. If the amount is not enough, it may require increasing the fee. const estimatedFee: Assets = { @@ -520,13 +544,13 @@ const doCoinSelection = ( // Note: We are not done with coin selection even if "requiredAssets" is empty. // Because "notRequiredAssets" may not contain enough ADA to cover for minimum Ada requirement // when they need to be sent as change output. Hence, we allow for "recursive" to be invoked. - const selected = yield* recursive( + return yield* recursive( sortUTxOs(availableInputs), requiredAssets, config.lucidConfig.protocolParameters.coinsPerUtxoByte, notRequiredAssets, + includeTinyChangeInFee, ); - return selected; }); /** @@ -745,8 +769,9 @@ export const recursive = ( requiredAssets: Assets, coinsPerUtxoByte: bigint, externalAssets: Assets = {}, + includeTinyChangeInFee?: boolean, error?: TxBuilderError, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { let selected: UTxO[] = []; error ??= completeTxError( @@ -779,6 +804,8 @@ export const recursive = ( const extraSelected = selectUTxOs(remainingInputs, extraLovelace); if (_Array.isEmptyArray(extraSelected)) { + if (includeTinyChangeInFee) + return { selected: [...selected], burnable: extraLovelace }; yield* completeTxError( `Your wallet does not have enough funds to cover required minimum ADA for change output: ${stringify(extraLovelace)} Or it contains UTxOs with reference scripts; which are excluded from coin selection.`, @@ -797,5 +824,5 @@ export const recursive = ( Option.getOrUndefined, ); } - return selected; + return { selected, burnable: { lovelace: 0n } }; }); diff --git a/packages/lucid/test/emulator/emulator.test.ts b/packages/lucid/test/emulator/emulator.test.ts index 0660d539..0a686f23 100644 --- a/packages/lucid/test/emulator/emulator.test.ts +++ b/packages/lucid/test/emulator/emulator.test.ts @@ -17,6 +17,7 @@ import { signByWalletFromPrivateKey, emulatorFromPrivateKey, depositFundsLockRefScript, + sendAllFund, } from "./service.js"; const distributeRewards = Effect.gen(function* ($) { @@ -164,4 +165,11 @@ describe.sequential("Emulator", () => { emulatorFromPrivateKey.awaitBlock(4); expect(exit._tag).toBe("Success"); }); + test("sendAllFund", async () => { + const program = pipe(sendAllFund, Effect.provide(EmulatorUser.layer)); + const exit = await Effect.runPromiseExit(program); + emulator.awaitBlock(4); + emulatorFromPrivateKey.awaitBlock(4); + expect(exit._tag).toBe("Success"); + }); }); diff --git a/packages/lucid/test/emulator/service.ts b/packages/lucid/test/emulator/service.ts index 0f2ca8fe..c1f69574 100644 --- a/packages/lucid/test/emulator/service.ts +++ b/packages/lucid/test/emulator/service.ts @@ -47,7 +47,7 @@ export const emulatorFromPrivateKey = await Effect.gen(function* () { }).pipe(Effect.runPromise); export const emulator = await Effect.gen(function* () { - return new Emulator([EMULATOR_ACCOUNT]); + return new Emulator([EMULATOR_ACCOUNT, EMULATOR_ACCOUNT_1]); }).pipe(Effect.runPromise); const makeEmulatorUser = Effect.gen(function* ($) { @@ -327,3 +327,51 @@ export const depositFundsLockRefScript = Effect.gen(function* ($) { .completeProgram(); return signBuilder; }).pipe(Effect.flatMap(handleSignSubmitWithoutValidation), withLogRetry); + +export const sendAllFund = Effect.gen(function* ($) { + const { user } = yield* EmulatorUser; + user.selectWallet.fromSeed(EMULATOR_ACCOUNT_1.seedPhrase); + const publicKeyHash = getAddressDetails( + yield* Effect.promise(() => user.wallet().address()), + ).paymentCredential?.hash; + const datum = Data.to(new Constr(0, [publicKeyHash!])); + + const helloCBOR = yield* pipe( + Effect.fromNullable( + scripts.validators.find( + (v) => v.title === "hello_world.hello_world.spend", + ), + ), + Effect.andThen((script) => script.compiledCode), + ); + const hello: SpendingValidator = { + type: "PlutusV3", + script: applyDoubleCborEncoding(helloCBOR), + }; + const contractAddress = validatorToAddress("Custom", hello); + yield* pipe( + Effect.tryPromise(() => user.utxosAt(contractAddress)), + // Effect.andThen((utxos) => Console.log(utxos)), + ); + + const utxos = yield* Effect.promise(() => user.wallet().getUtxos()); + let totalFund = 0n; + for (const utxo of utxos) { + totalFund += utxo.assets["lovelace"]; + } + let remaining = 500_000n; + + const signBuilder = yield* user + .newTx() + .pay.ToAddressWithData( + contractAddress, + { + kind: "inline", + value: datum, + }, + { lovelace: totalFund - remaining }, + hello, + ) + .completeProgram({ includeTinyChangeInFee: true }); + return signBuilder; +}).pipe(Effect.flatMap(handleSignSubmitWithoutValidation), withLogRetry);