From d0446f2e2ce9d91e6e514cba97c9ba101c83b66b Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Mon, 27 May 2024 18:50:43 -0400 Subject: [PATCH] feat: add coin selection algorithm --- .changeset/tidy-books-count.md | 6 + packages/lucid/src/Errors.ts | 2 +- .../lucid/src/tx-builder/MakeTxBuilder.ts | 5 +- .../lucid/src/tx-builder/internal/Collect.ts | 3 +- .../tx-builder/internal/CompleteTxBuilder.ts | 241 ++++++++++------ .../lucid/src/tx-builder/internal/Mint.ts | 2 + packages/lucid/src/tx-builder/internal/Pay.ts | 4 +- .../lucid/src/tx-builder/internal/Read.ts | 2 +- .../lucid/src/tx-builder/internal/TxUtils.ts | 2 + packages/lucid/src/tx-builder/types.ts | 7 +- packages/lucid/test/onchain.test.ts | 72 +++++ packages/lucid/test/specs/hello.ts | 2 +- packages/lucid/test/specs/mint-burn.ts | 270 ++++++++++++++++++ packages/lucid/test/tx.test.ts | 105 +++++-- packages/utils/src/utxo.ts | 72 ++++- packages/utils/src/value.ts | 9 +- 16 files changed, 673 insertions(+), 131 deletions(-) create mode 100644 .changeset/tidy-books-count.md create mode 100644 packages/lucid/test/specs/mint-burn.ts diff --git a/.changeset/tidy-books-count.md b/.changeset/tidy-books-count.md new file mode 100644 index 00000000..cb6526b6 --- /dev/null +++ b/.changeset/tidy-books-count.md @@ -0,0 +1,6 @@ +--- +"@lucid-evolution/lucid": patch +"@lucid-evolution/utils": patch +--- + +add coin selection algorithm, input selection is done in descending order diff --git a/packages/lucid/src/Errors.ts b/packages/lucid/src/Errors.ts index 5959b9ed..073691f1 100644 --- a/packages/lucid/src/Errors.ts +++ b/packages/lucid/src/Errors.ts @@ -7,7 +7,7 @@ export type TxBuilderErrorCause = | "Provider" | "EmptyUTXO" | "EmptyAssets" - | "MissingCollateralInput" + | "MissingCollateral" | "MultiplePolicies" | "InvalidNetwork" | "InvalidMetadata" diff --git a/packages/lucid/src/tx-builder/MakeTxBuilder.ts b/packages/lucid/src/tx-builder/MakeTxBuilder.ts index fcca565c..ff8c63b6 100644 --- a/packages/lucid/src/tx-builder/MakeTxBuilder.ts +++ b/packages/lucid/src/tx-builder/MakeTxBuilder.ts @@ -80,7 +80,10 @@ export function makeTxBuilder(lucidConfig: LucidConfig): TxBuilder { const config: TxBuilderConfig = { lucidConfig: lucidConfig, txBuilder: CML.TransactionBuilder.new(lucidConfig.txbuilderconfig), - inputUTxOs: [], + collectedInputs: [], + readInputs: [], + totalOutputAssets: {}, + mintedAssets: {}, scripts: new Map(), programs: [], }; diff --git a/packages/lucid/src/tx-builder/internal/Collect.ts b/packages/lucid/src/tx-builder/internal/Collect.ts index 4feafd45..14203641 100644 --- a/packages/lucid/src/tx-builder/internal/Collect.ts +++ b/packages/lucid/src/tx-builder/internal/Collect.ts @@ -37,7 +37,8 @@ export const collectFromUTxO = ( const coreUtxo = utxoToCore(utxo); // An array of unspent transaction outputs to be used as inputs when running uplc eval. - config.inputUTxOs?.push(utxo); + config.collectedInputs.push(utxo); + //TODO: Add config.collectedAssets const input = CML.SingleInputBuilder.from_transaction_unspent_output(coreUtxo); const credential = paymentCredentialOf(utxo.address); diff --git a/packages/lucid/src/tx-builder/internal/CompleteTxBuilder.ts b/packages/lucid/src/tx-builder/internal/CompleteTxBuilder.ts index a8f7b40f..cab37107 100644 --- a/packages/lucid/src/tx-builder/internal/CompleteTxBuilder.ts +++ b/packages/lucid/src/tx-builder/internal/CompleteTxBuilder.ts @@ -1,5 +1,5 @@ -import { Effect, pipe } from "effect"; -import { Address, OutputData, UTxO } from "@lucid-evolution/core-types"; +import { Effect, pipe, Record, Array as _Array } from "effect"; +import { Address, Assets, OutputData, UTxO } from "@lucid-evolution/core-types"; import { TxBuilderConfig } from "../types.js"; import { ERROR_MESSAGE, @@ -11,8 +11,9 @@ import { CML, makeReturn } from "../../core.js"; import { makeTxSignBuilder } from "../../tx-sign-builder/MakeTxSign.js"; import * as UPLC from "@lucid-evolution/uplc"; import { - createCostModels, - utxosToCores, + isEqualUTxO, + selectUTxOs, + sortUTxOs, utxoToCore, utxoToTransactionInput, utxoToTransactionOutput, @@ -24,94 +25,73 @@ export const completeTxError = (cause: TxBuilderErrorCause, message?: string) => export const completeTxBuilder = ( config: TxBuilderConfig, - options?: { + options: { change?: { address?: Address; outputData?: OutputData }; coinSelection?: boolean; nativeUplc?: boolean; - }, + } = { coinSelection: true }, ) => { - const program = Effect.gen(function* ($) { - const wallet = yield* $( + const program = Effect.gen(function* () { + //NOTE: this should not be here, validation shuold be when making the tx builder + const wallet = yield* pipe( Effect.fromNullable(config.lucidConfig.wallet), Effect.orElseFail(() => completeTxError("MissingWallet", ERROR_MESSAGE.MISSING_WALLET), ), ); + //NOTE: this should not be here, validation shuold be when making the tx builderj + const changeAddress = yield* Effect.promise(() => wallet.address()); - yield* $(Effect.all(config.programs, { concurrency: "unbounded" })); + yield* Effect.all(config.programs, { concurrency: "unbounded" }); - const walletUtxos = yield* $( + //NOTE: this should not be here, validation shuold be when making the tx builderj + const walletUtxos = yield* pipe( Effect.tryPromise({ try: () => wallet.getUtxos(), catch: (error) => completeTxError("Provider", String(error)), }), ); - const walletCoreUtxos = utxosToCores(walletUtxos); //TODO: add multiple input collateral based one: // max_collateral_inputs 3 The maximum number of collateral inputs allowed in a transaction. - if (config.inputUTxOs?.find((value) => value.datum !== undefined)) { - const collateralInput: UTxO = yield* $( - Effect.fromNullable( - walletUtxos.find((value) => value.assets["lovelace"] >= 5_000_000n), - ), - Effect.orElseFail(() => - completeTxError("MissingCollateralInput", "No collateralInput found"), - ), + if (config.collectedInputs.find((value) => value.datum !== undefined)) { + //Remove collected inputs from utxos at wallet and utxo selected for collateral + const remainingInputs = _Array.differenceWith(isEqualUTxO)( + walletUtxos, + config.collectedInputs, ); - const collateralInputCore = utxoToCore(collateralInput); - const collateralOut = utxoToTransactionOutput(collateralInput); - - config.txBuilder.add_collateral( - CML.SingleInputBuilder.from_transaction_unspent_output( - collateralInputCore, - ).payment_key(), - ); - const collateralOutputBuilder = - CML.TransactionOutputBuilder.new().with_address( - CML.Address.from_bech32(collateralInput.address), - ); - //TODO: calculate percentage - //collateral_percent 150 The percentage of the txfee which must be provided as collateral when including non-native scripts. - config.txBuilder.set_collateral_return( - collateralOutputBuilder - .next() - .with_asset_and_min_required_coin( - collateralOut.amount().multi_asset(), - config.lucidConfig.protocolParameters.coinsPerUtxoByte, - ) - .build() - .output(), + const collateralInput = yield* findCollateral(remainingInputs); + setCollateral(config, collateralInput); + const availableInputs = _Array.differenceWith(isEqualUTxO)( + remainingInputs, + [collateralInput], ); - if (options?.coinSelection || options?.coinSelection === undefined) { - const filteredUtxo = walletCoreUtxos.filter( - (value) => value.to_cbor_hex() !== collateralInputCore.to_cbor_hex(), - ); - for (const utxo of filteredUtxo) { - const input = - CML.SingleInputBuilder.from_transaction_unspent_output( - utxo, - ).payment_key(); - config.txBuilder.add_input(input); - } - config.txBuilder.select_utxos( - CML.CoinSelectionStrategyCIP2.LargestFirst, - ); + const inputsToAdd = options.coinSelection + ? yield* coinSelection(config, availableInputs) + : availableInputs; + + for (const utxo of inputsToAdd) { + const input = CML.SingleInputBuilder.from_transaction_unspent_output( + utxoToCore(utxo), + ).payment_key(); + config.txBuilder.add_input(input); } } else { - if (options?.coinSelection || options?.coinSelection === undefined) { - for (const utxo of walletCoreUtxos) { - const input = - CML.SingleInputBuilder.from_transaction_unspent_output( - utxo, - ).payment_key(); - config.txBuilder.add_input(input); - } - config.txBuilder.select_utxos( - CML.CoinSelectionStrategyCIP2.LargestFirst, - ); + //Remove collected inputs from utxos at wallet + const availableInputs = _Array.differenceWith(isEqualUTxO)( + walletUtxos, + config.collectedInputs, + ); + const inputsToAdd = options.coinSelection + ? yield* coinSelection(config, availableInputs) + : availableInputs; + + for (const utxo of inputsToAdd) { + const input = CML.SingleInputBuilder.from_transaction_unspent_output( + utxoToCore(utxo), + ).payment_key(); + config.txBuilder.add_input(input); } } - const changeAddress = yield* $(Effect.promise(() => wallet.address())); const slotConfig = SLOT_CONFIG_NETWORK[config.lucidConfig.network]; // config.txBuilder.add_change_if_needed( @@ -126,31 +106,33 @@ export const completeTxBuilder = ( //FIX: this returns undefined const txEvaluation = setRedeemerstoZero(tx_evaluation.draft_tx())!; // console.log(txEvaluation?.to_json()); - const txUtxos = [...walletUtxos, ...config.inputUTxOs!]; + const txUtxos = [ + ...walletUtxos, + ...config.collectedInputs, + ...config.readInputs, + ]; const ins = txUtxos.map((utxo) => utxoToTransactionInput(utxo)); const outs = txUtxos.map((utxo) => utxoToTransactionOutput(utxo)); - const uplc_eval = yield* $( - Effect.try({ - try: () => - UPLC.eval_phase_two_raw( - txEvaluation.to_cbor_bytes(), - ins.map((value) => value.to_cbor_bytes()), - outs.map((value) => value.to_cbor_bytes()), - config.lucidConfig.costModels.to_cbor_bytes(), - config.lucidConfig.protocolParameters.maxTxExSteps, - config.lucidConfig.protocolParameters.maxTxExMem, - BigInt(slotConfig.zeroTime), - BigInt(slotConfig.zeroSlot), - slotConfig.slotLength, - ), - catch: (error) => - //TODO: improve format - completeTxError( - "UPLCEval", - JSON.stringify(error).replace(/\\n/g, ""), - ), - }), - ); + const uplc_eval = yield* Effect.try({ + try: () => + UPLC.eval_phase_two_raw( + txEvaluation.to_cbor_bytes(), + ins.map((value) => value.to_cbor_bytes()), + outs.map((value) => value.to_cbor_bytes()), + config.lucidConfig.costModels.to_cbor_bytes(), + config.lucidConfig.protocolParameters.maxTxExSteps, + config.lucidConfig.protocolParameters.maxTxExMem, + BigInt(slotConfig.zeroTime), + BigInt(slotConfig.zeroSlot), + slotConfig.slotLength, + ), + catch: (error) => + //TODO: improve format + completeTxError( + "UPLCEval", + JSON.stringify(error).replace(/\\n/g, ""), + ), + }); applyUPLCEval(uplc_eval, config.txBuilder); } config.txBuilder.add_change_if_needed( @@ -164,7 +146,6 @@ export const completeTxBuilder = ( CML.Address.from_bech32(changeAddress), ) .build_unchecked(); - config.txBuilder.free(); return makeTxSignBuilder(config.lucidConfig, tx); }).pipe(Effect.catchAllDefect(makeRunTimeError)); @@ -232,3 +213,79 @@ export const outputToArray = (outputList: CML.TransactionOutputList) => { } return array; }; + +const setCollateral = (config: TxBuilderConfig, input: UTxO) => { + config.txBuilder.add_collateral( + CML.SingleInputBuilder.from_transaction_unspent_output( + utxoToCore(input), + ).payment_key(), + ); + const collateralOutputBuilder = + CML.TransactionOutputBuilder.new().with_address( + CML.Address.from_bech32(input.address), + ); + //TODO: calculate percentage + //collateral_percent 150 The percentage of the txfee which must be provided as collateral when including non-native scripts. + config.txBuilder.set_collateral_return( + collateralOutputBuilder + .next() + .with_asset_and_min_required_coin( + utxoToTransactionOutput(input).amount().multi_asset(), + config.lucidConfig.protocolParameters.coinsPerUtxoByte, + ) + .build() + .output(), + ); +}; + +const findCollateral = (inputs: UTxO[]) => + pipe( + Effect.fromNullable( + sortUTxOs(inputs, "ascending").find( + (value) => value.assets["lovelace"] >= 5_000_000n, + ), + ), + Effect.orElseFail(() => + completeTxError( + "MissingCollateral", + "Your wallet does not have enough funds to cover the required collateral.", + ), + ), + ); + +//coinSelection is seach inputs by largest first +const coinSelection = (config: TxBuilderConfig, availableInputs: UTxO[]) => + Effect.gen(function* () { + // NOTE: This is a fee estimation. If the amount is not enough, it may require increasing the fee. + const fee: Assets = { lovelace: config.txBuilder.min_fee(false) }; + // yield* Console.log("totalOutputAssets", config.totalOutputAssets); + const requiredMinted = Record.map(config.mintedAssets, (amount) => -amount); + // yield* Console.log("requiredMinted", requiredMinted); + const collectedAssets = _Array.isEmptyArray(config.collectedInputs) + ? {} + : config.collectedInputs + .map((utxo) => utxo.assets) + .reduce((acc, cur) => + Record.union(acc, cur, (self, that) => self + that), + ); + const collected = Record.map(collectedAssets, (amount) => -amount); + // yield* Console.log("collected", collected); + + const requiredAssets = pipe( + config.totalOutputAssets, + Record.union(collected, (self, that) => self + that), + Record.union(requiredMinted, (self, that) => self + that), + Record.filter((amount) => amount > 0n), + Record.union(fee, (self, that) => self + that), //NOTE: fee be at the end so the wallet can pay for the tx + ); + + // yield* Console.log("requiredAssets", requiredAssets); + + const selected = selectUTxOs(availableInputs, requiredAssets); + if (selected.length == 0) + yield* completeTxError( + "NotFound", + "Your wallet does not have enough funds to cover the required assets.", + ); + return selected; + }); diff --git a/packages/lucid/src/tx-builder/internal/Mint.ts b/packages/lucid/src/tx-builder/internal/Mint.ts index 091bf5ee..94f99dba 100644 --- a/packages/lucid/src/tx-builder/internal/Mint.ts +++ b/packages/lucid/src/tx-builder/internal/Mint.ts @@ -9,6 +9,7 @@ import { TxBuilderErrorCause, } from "../../Errors.js"; import { TxBuilderConfig } from "../types.js"; +import { addAssets } from "@lucid-evolution/utils"; export const mintError = (cause: TxBuilderErrorCause, message?: string) => new TxBuilderError({ cause, module: "Mint", message }); @@ -24,6 +25,7 @@ export const mintAssets = ( redeemer?: Redeemer, ): Effect.Effect => Effect.gen(function* () { + config.mintedAssets = addAssets(config.mintedAssets, assets); const units = Object.keys(assets); const policyId = units[0].slice(0, 56); const mintAssets = CML.MapAssetNameToNonZeroInt64.new(); diff --git a/packages/lucid/src/tx-builder/internal/Pay.ts b/packages/lucid/src/tx-builder/internal/Pay.ts index e8515204..2a2319fa 100644 --- a/packages/lucid/src/tx-builder/internal/Pay.ts +++ b/packages/lucid/src/tx-builder/internal/Pay.ts @@ -1,5 +1,5 @@ import { Effect, Scope } from "effect"; -import { assetsToValue, toScriptRef } from "@lucid-evolution/utils"; +import { addAssets, assetsToValue, toScriptRef } from "@lucid-evolution/utils"; import { Address, Assets, Script } from "@lucid-evolution/core-types"; import { OutputDatum, TxBuilderConfig } from "../types.js"; import { CML } from "../../core.js"; @@ -16,6 +16,7 @@ export const payToAddress = ( assets: Assets, ) => Effect.gen(function* () { + config.totalOutputAssets = addAssets(config.totalOutputAssets, assets); const outputBuilder = CML.TransactionOutputBuilder.new() .with_address(yield* toCMLAddress(address, config.lucidConfig)) .next(); @@ -55,6 +56,7 @@ export const payToAddressWithData = ( //TODO: Test with datumhash const outputBuilder = buildBaseOutput(address, outputDatum, scriptRef); if (assets) { + config.totalOutputAssets = addAssets(config.totalOutputAssets, assets); if (Object.keys(assets).length == 0) yield* payError( "EmptyAssets", diff --git a/packages/lucid/src/tx-builder/internal/Read.ts b/packages/lucid/src/tx-builder/internal/Read.ts index 6d63b079..730edef6 100644 --- a/packages/lucid/src/tx-builder/internal/Read.ts +++ b/packages/lucid/src/tx-builder/internal/Read.ts @@ -31,7 +31,7 @@ export const readFrom = ( } const coreUtxo = utxoToCore(utxo); // An array of unspent transaction outputs to be used as inputs when running uplc eval. - config.inputUTxOs?.push(utxo); + config.readInputs.push(utxo); config.txBuilder.add_reference_input(coreUtxo); } }); diff --git a/packages/lucid/src/tx-builder/internal/TxUtils.ts b/packages/lucid/src/tx-builder/internal/TxUtils.ts index a28e6658..79de3e9c 100644 --- a/packages/lucid/src/tx-builder/internal/TxUtils.ts +++ b/packages/lucid/src/tx-builder/internal/TxUtils.ts @@ -5,7 +5,9 @@ import { networkToId } from "@lucid-evolution/utils"; import { Address, AddressDetails, + Assets, RewardAddress, + UTxO, } from "@lucid-evolution/core-types"; import { TxBuilderError } from "../../Errors.js"; import { LucidConfig } from "../../lucid-evolution/LucidEvolution.js"; diff --git a/packages/lucid/src/tx-builder/types.ts b/packages/lucid/src/tx-builder/types.ts index 94281197..ca6fe910 100644 --- a/packages/lucid/src/tx-builder/types.ts +++ b/packages/lucid/src/tx-builder/types.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import { ScriptType, UTxO } from "@lucid-evolution/core-types"; +import { Assets, ScriptType, UTxO } from "@lucid-evolution/core-types"; import { CML } from "../core.js"; import { TransactionError } from "../Errors.js"; import { LucidConfig } from "../lucid-evolution/LucidEvolution.js"; @@ -7,7 +7,10 @@ import { LucidConfig } from "../lucid-evolution/LucidEvolution.js"; export type TxBuilderConfig = { readonly lucidConfig: LucidConfig; readonly txBuilder: CML.TransactionBuilder; - inputUTxOs: UTxO[]; + collectedInputs: UTxO[]; + readInputs: UTxO[]; + totalOutputAssets: Assets; + mintedAssets: Assets; scripts: Map; programs: Effect.Effect[]; }; diff --git a/packages/lucid/test/onchain.test.ts b/packages/lucid/test/onchain.test.ts index 7aba5f67..cd2519b6 100644 --- a/packages/lucid/test/onchain.test.ts +++ b/packages/lucid/test/onchain.test.ts @@ -3,6 +3,7 @@ import { Effect, Layer, pipe } from "effect"; import { HelloContract, User } from "./specs/services.js"; import * as HelloEndpoints from "./specs/hello.js"; import * as StakeEndpoints from "./specs/stake.js"; +import * as MintBurnEndpoints from "./specs/mint-burn.js"; import * as ParametrizedEndpoints from "./specs/hello-params.js"; describe.sequential("Hello", () => { @@ -111,3 +112,74 @@ describe.sequential("Parametrized Contract", () => { expect(exit._tag).toBe("Success"); }); }); + +describe.sequential("Mint Test", () => { + test("Mint Token", async () => { + const program = pipe( + MintBurnEndpoints.mint, + Effect.provide(Layer.mergeAll(User.layer)), + ); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); + }); + + test("Burn Token", async () => { + const program = pipe( + MintBurnEndpoints.burn, + Effect.provide(Layer.mergeAll(User.layer)), + ); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); + }); + + test("Mint/Burn Token", async () => { + const program = pipe( + MintBurnEndpoints.mintburn, + Effect.provide(Layer.mergeAll(User.layer)), + ); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); + }); + + test("Mint Token, Second Test", async () => { + const program = pipe( + MintBurnEndpoints.mint2, + Effect.provide(Layer.mergeAll(User.layer)), + ); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); + }); + + test("Burn Token, Second Test", async () => { + const program = pipe( + MintBurnEndpoints.burn2, + Effect.provide(Layer.mergeAll(User.layer)), + ); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); + }); + test("Pay ADA", async () => { + const program = pipe( + MintBurnEndpoints.pay, + Effect.provide(Layer.mergeAll(User.layer)), + ); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); + }); + test("Pay Asset", async () => { + const program = pipe( + MintBurnEndpoints.pay2, + Effect.provide(Layer.mergeAll(User.layer)), + ); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); + }); + test("CollectFunds", async () => { + const program = pipe( + MintBurnEndpoints.pay3, + Effect.provide(Layer.mergeAll(User.layer, HelloContract.layer)), + ); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); + }); +}); diff --git a/packages/lucid/test/specs/hello.ts b/packages/lucid/test/specs/hello.ts index 02d0d5a8..39352d96 100644 --- a/packages/lucid/test/specs/hello.ts +++ b/packages/lucid/test/specs/hello.ts @@ -87,7 +87,7 @@ export const collectFunds = Effect.gen(function* ($) { yield* handleSignSubmit(signBuilder); }).pipe( - Effect.tapErrorCause(Effect.logDebug), + Effect.tapErrorCause(Console.log), Effect.retry( pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), ), diff --git a/packages/lucid/test/specs/mint-burn.ts b/packages/lucid/test/specs/mint-burn.ts new file mode 100644 index 00000000..04695c4d --- /dev/null +++ b/packages/lucid/test/specs/mint-burn.ts @@ -0,0 +1,270 @@ +import { + fromText, + mintingPolicyToId, + nativeJSFromJson, + paymentCredentialOf, + selectUTxOs, +} from "../../src/index.js"; +import { Effect, Logger, LogLevel, pipe, Schedule } from "effect"; +import { User } from "./services.js"; + +const mkMintinPolicy = (time: number, address: string) => { + return nativeJSFromJson({ + type: "all", + scripts: [ + { + type: "sig", + keyHash: paymentCredentialOf(address).hash, + }, + ], + }); +}; +export const mint = Effect.gen(function* () { + const { user } = yield* User; + const utxo = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + const addr = yield* Effect.promise(() => user.wallet().address()); + const mint = mkMintinPolicy(9_000_000, addr); + const policy = mintingPolicyToId(mint); + + const signBuilder = yield* user + .newTx() + .pay.ToAddress( + "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + { [policy + fromText("BurnableToken")]: 1n }, + ) + .mintAssets({ + [policy + fromText("BurnableToken")]: 1n, + [policy + fromText("BurnableToken2")]: 1n, + }) + .validTo(Date.now() + 900000) + .attach.MintingPolicy(mint) + .completeProgram(); + const signed = yield* signBuilder.sign.withWallet().completeProgram(); + const txHash = yield* signed.submitProgram(); + yield* Effect.sleep("10 seconds"); + yield* Effect.logDebug(txHash); +}).pipe( + Effect.tapError(Effect.logDebug), + Effect.retry( + pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), + ), + Logger.withMinimumLogLevel(LogLevel.Debug), +); + +export const burn = Effect.gen(function* () { + const { user } = yield* User; + const utxo = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + const addr = yield* Effect.promise(() => user.wallet().address()); + const mint = mkMintinPolicy(9_000_000, addr); + const policy = mintingPolicyToId(mint); + + const signBuilder = yield* user + .newTx() + .pay.ToAddress( + "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + { lovelace: 2_000_000n }, + ) + .mintAssets({ [policy + fromText("BurnableToken")]: -1n }) + .validTo(Date.now() + 900000) + .attach.MintingPolicy(mint) + .completeProgram(); + const signed = yield* signBuilder.sign.withWallet().completeProgram(); + const txHash = yield* signed.submitProgram(); + yield* Effect.sleep("10 seconds"); + yield* Effect.logDebug(txHash); +}).pipe( + Effect.tapError(Effect.logDebug), + Effect.retry( + pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), + ), + Logger.withMinimumLogLevel(LogLevel.Debug), +); + +export const mintburn = Effect.gen(function* () { + const { user } = yield* User; + const utxo = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + const addr = yield* Effect.promise(() => user.wallet().address()); + const mint = mkMintinPolicy(9_000_000, addr); + const policy = mintingPolicyToId(mint); + + const signBuilder = yield* user + .newTx() + .pay.ToAddress( + "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + { [policy + fromText("BurnableToken")]: 1n }, + ) + .mintAssets({ + [policy + fromText("BurnableToken")]: 1n, + [policy + fromText("BurnableToken2")]: -1n, + }) + .validTo(Date.now() + 900000) + .attach.MintingPolicy(mint) + .completeProgram(); + const signed = yield* signBuilder.sign.withWallet().completeProgram(); + const txHash = yield* signed.submitProgram(); + yield* Effect.sleep("10 seconds"); + yield* Effect.logDebug(txHash); +}).pipe( + Effect.tapError(Effect.logDebug), + Effect.retry( + pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), + ), + Logger.withMinimumLogLevel(LogLevel.Debug), +); + +export const mint2 = Effect.gen(function* () { + const { user } = yield* User; + const utxo = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + const addr = yield* Effect.promise(() => user.wallet().address()); + const mint = mkMintinPolicy(9_000_000, addr); + const policy = mintingPolicyToId(mint); + + const signBuilder = yield* user + .newTx() + .mintAssets({ + [policy + fromText("BurnableToken")]: 1n, + [policy + fromText("BurnableToken2")]: 1n, + }) + .validTo(Date.now() + 900000) + .attach.MintingPolicy(mint) + .completeProgram(); + const signed = yield* signBuilder.sign.withWallet().completeProgram(); + const txHash = yield* signed.submitProgram(); + yield* Effect.sleep("10 seconds"); + yield* Effect.logDebug(txHash); +}).pipe( + Effect.tapError(Effect.logDebug), + Effect.retry( + pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), + ), + Logger.withMinimumLogLevel(LogLevel.Debug), +); + +export const burn2 = Effect.gen(function* () { + const { user } = yield* User; + const utxo = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + const addr = yield* Effect.promise(() => user.wallet().address()); + const mint = mkMintinPolicy(9_000_000, addr); + const policy = mintingPolicyToId(mint); + + const signBuilder = yield* user + .newTx() + .mintAssets({ + [policy + fromText("BurnableToken")]: -1n, + [policy + fromText("BurnableToken2")]: -1n, + }) + .validTo(Date.now() + 900000) + .attach.MintingPolicy(mint) + .completeProgram(); + const signed = yield* signBuilder.sign.withWallet().completeProgram(); + const txHash = yield* signed.submitProgram(); + yield* Effect.sleep("10 seconds"); + yield* Effect.logDebug(txHash); +}).pipe( + Effect.tapError(Effect.logDebug), + Effect.retry( + pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), + ), + Logger.withMinimumLogLevel(LogLevel.Debug), +); + +export const pay = Effect.gen(function* () { + const { user } = yield* User; + const utxo = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + const addr = yield* Effect.promise(() => user.wallet().address()); + const mint = mkMintinPolicy(9_000_000, addr); + const policy = mintingPolicyToId(mint); + + const signBuilder = yield* user + .newTx() + .pay.ToAddress( + "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + { lovelace: 2_000_000n }, + ) + .validTo(Date.now() + 900000) + .attach.MintingPolicy(mint) + .completeProgram(); + const signed = yield* signBuilder.sign.withWallet().completeProgram(); + const txHash = yield* signed.submitProgram(); + yield* Effect.sleep("10 seconds"); + yield* Effect.logDebug(txHash); +}).pipe( + Effect.tapError(Effect.logDebug), + Effect.retry( + pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), + ), + Logger.withMinimumLogLevel(LogLevel.Debug), +); + +export const pay2 = Effect.gen(function* () { + const { user } = yield* User; + const utxos = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + const addr = yield* Effect.promise(() => user.wallet().address()); + const mint = mkMintinPolicy(9_000_000, addr); + const policy = mintingPolicyToId(mint); + + const signBuilder = yield* user + .newTx() + .pay.ToAddress( + "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + { [policy + fromText("BurnableToken2")]: 1n }, + ) + .validTo(Date.now() + 900000) + .attach.MintingPolicy(mint) + .completeProgram(); + const signed = yield* signBuilder.sign.withWallet().completeProgram(); + const txHash = yield* signed.submitProgram(); + yield* Effect.sleep("10 seconds"); + yield* Effect.logDebug(txHash); +}).pipe( + Effect.tapError(Effect.logDebug), + Effect.retry( + pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), + ), + Logger.withMinimumLogLevel(LogLevel.Debug), +); + +export const pay3 = Effect.gen(function* () { + const { user } = yield* User; + const utxos = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + const addr = yield* Effect.promise(() => user.wallet().address()); + const mint = mkMintinPolicy(9_000_000, addr); + const policy = mintingPolicyToId(mint); + + const collecAssets = { + ["1c05caed08ddd5c9f233f4cb497eeb6e5f685e8e7b842b08897d1dfe4d794d696e746564546f6b656e"]: + 1n, + ["501b8b9dce8d7c1247a14bb69d416c621267daa72ebd6c81942931924d794d696e746564546f6b656e"]: + 1n, + ["665d4dbea856001b880d5749e94384cc486d8c4ee99540d2f65d15704d794d696e746564546f6b656e"]: + 1n, + }; + + const signBuilder = yield* user + .newTx() + .collectFrom(selectUTxOs(utxos, collecAssets)) + .pay.ToAddress( + "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + { [policy + fromText("BurnableToken2")]: 1n }, + ) + .pay.ToAddress( + "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + { + ["665d4dbea856001b880d5749e94384cc486d8c4ee99540d2f65d15704d794d696e746564546f6b656e"]: + 1n, + }, + ) + .validTo(Date.now() + 900000) + .attach.MintingPolicy(mint) + .completeProgram(); + const signed = yield* signBuilder.sign.withWallet().completeProgram(); + const txHash = yield* signed.submitProgram(); + yield* Effect.sleep("10 seconds"); + yield* Effect.logDebug(txHash); +}).pipe( + Effect.tapErrorCause(Effect.logError), + Effect.retry( + pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), + ), + Logger.withMinimumLogLevel(LogLevel.Debug), +); diff --git a/packages/lucid/test/tx.test.ts b/packages/lucid/test/tx.test.ts index d3fd72f7..cb6d89e7 100644 --- a/packages/lucid/test/tx.test.ts +++ b/packages/lucid/test/tx.test.ts @@ -5,8 +5,8 @@ import { paymentCredentialOf, unixTimeToSlot, } from "../src/index.js"; -import { test } from "vitest"; -import { Effect, Logger, LogLevel, pipe, Schedule } from "effect"; +import { expect, test } from "vitest"; +import { Effect, Layer, Logger, LogLevel, pipe, Schedule } from "effect"; import { User } from "./specs/services.js"; const mkMintinPolicy = (time: number, address: string) => { return nativeJSFromJson({ @@ -16,38 +16,91 @@ const mkMintinPolicy = (time: number, address: string) => { type: "sig", keyHash: paymentCredentialOf(address).hash, }, - { - type: "before", - slot: unixTimeToSlot("Preprod", time + Date.now()), - }, + // { + // type: "before", + // slot: unixTimeToSlot("Preprod", time + Date.now()), + // }, ], }); }; +export const txburn = Effect.gen(function* ($) { + const { user } = yield* User; + const utxo = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + console.log(utxo); + const addr = yield* Effect.promise(() => user.wallet().address()); + const mint = mkMintinPolicy(9_000_000, addr); + const policy = mintingPolicyToId(mint); + + const signBuilder = yield* user + .newTx() + // .readFrom(utxo) + .pay.ToAddress( + "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + { lovelace: 2_000_000n }, + // { + // "eb8b660cf939281c277264389c4086e7c79baf78e08d0c48668420ab4d794d696e746564546f6b656e": + // 1n, + // } + // { [policy + fromText("BurnableToken")]: -1n } + ) + // .pay.ToAddressWithData( + // "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + // { + // kind: "inline", + // value: "d87980", + // }, + // { lovelace: 10_000_000n } + // // { [policy + fromText("MyMintedToken")]: 1n } + // ) + .mintAssets({ [policy + fromText("BurnableToken")]: -1n }) + .validTo(Date.now() + 900000) + .attach.MintingPolicy(mint) + .completeProgram(); + const signed = yield* signBuilder.sign.withWallet().completeProgram(); + const txHash = yield* signed.submitProgram(); + yield* Effect.sleep("10 seconds"); + yield* Effect.logDebug(txHash); +}).pipe( + Effect.tapError(Effect.logDebug), + Effect.retry( + pipe(Schedule.compose(Schedule.exponential(20_000), Schedule.recurs(4))), + ), + Logger.withMinimumLogLevel(LogLevel.Debug), +); export const txSubmit = Effect.gen(function* ($) { const { user } = yield* User; const utxo = yield* Effect.tryPromise(() => user.wallet().getUtxos()); + console.log(utxo); const addr = yield* Effect.promise(() => user.wallet().address()); const mint = mkMintinPolicy(9_000_000, addr); const policy = mintingPolicyToId(mint); const signBuilder = yield* user .newTx() - .readFrom(utxo) + // .readFrom(utxo) .pay.ToAddress( - "addr_test1qp4cgm42esrud5njskhsr6uc28s6ljah0phsdqe7qmh3rfuyjgq5wwsca5camufxavmtnm8f6ywga3de3jkgmkwzma4sqv284l", - // { lovelace: 2_000_000n }, - { [policy + fromText("MyMintedToken")]: 1n }, + "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + // { lovelace: 2_000_000n } + // { + // "eb8b660cf939281c277264389c4086e7c79baf78e08d0c48668420ab4d794d696e746564546f6b656e": + // 1n, + // } + { [policy + fromText("BurnableToken")]: 1n }, ) - .pay.ToAddressWithData( - "addr_test1qp4cgm42esrud5njskhsr6uc28s6ljah0phsdqe7qmh3rfuyjgq5wwsca5camufxavmtnm8f6ywga3de3jkgmkwzma4sqv284l", - { - kind: "inline", - value: "d87980", - }, - // { [policy + fromText("MyMintedToken")]: 1n } - ) - .mintAssets({ [policy + fromText("MyMintedToken")]: 1n }) + // .pay.ToAddressWithData( + // "addr_test1qrngfyc452vy4twdrepdjc50d4kvqutgt0hs9w6j2qhcdjfx0gpv7rsrjtxv97rplyz3ymyaqdwqa635zrcdena94ljs0xy950", + // { + // kind: "inline", + // value: "d87980", + // }, + // { lovelace: 10_000_000n } + // // { [policy + fromText("MyMintedToken")]: 1n } + // ) + .mintAssets({ + [policy + fromText("BurnableToken")]: 1n, + [policy + fromText("BurnableToken2")]: 1n, + }) .validTo(Date.now() + 900000) .attach.MintingPolicy(mint) .completeProgram(); @@ -63,8 +116,14 @@ export const txSubmit = Effect.gen(function* ($) { Logger.withMinimumLogLevel(LogLevel.Debug), ); -test("tx submit ", async () => { - // const program = pipe(txSubmit, Effect.provide(Layer.mergeAll(User.layer))); - // const exit = await Effect.runPromiseExit(program); - // expect(exit._tag).toBe("Success"); +test.skip("Mint Token", async () => { + const program = pipe(txSubmit, Effect.provide(Layer.mergeAll(User.layer))); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); +}); + +test.skip("Burn Token", async () => { + const program = pipe(txburn, Effect.provide(Layer.mergeAll(User.layer))); + const exit = await Effect.runPromiseExit(program); + expect(exit._tag).toBe("Success"); }); diff --git a/packages/utils/src/utxo.ts b/packages/utils/src/utxo.ts index 4f42c65b..39c96479 100644 --- a/packages/utils/src/utxo.ts +++ b/packages/utils/src/utxo.ts @@ -1,4 +1,4 @@ -import { OutRef, TxOutput, UTxO } from "@lucid-evolution/core-types"; +import { Assets, OutRef, TxOutput, UTxO } from "@lucid-evolution/core-types"; import { CML } from "./core.js"; import { fromScriptRef, toScriptRef } from "./scripts.js"; import { assetsToValue, valueToAssets } from "./value.js"; @@ -60,14 +60,13 @@ export function utxosToCores(utxos: UTxO[]): CML.TransactionUnspentOutput[] { return result; } +//TODO: test coreToUtxo -> utxoToCore strict equality export function coreToUtxo(coreUtxo: CML.TransactionUnspentOutput): UTxO { const out = CML.TransactionOutput.from_cbor_hex(coreUtxo.to_cbor_hex()); const utxo = { ...coreToOutRef(CML.TransactionInput.from_cbor_hex(coreUtxo.to_cbor_hex())), ...coreToTxOutput(out), }; - out.free(); - return utxo; } @@ -86,7 +85,7 @@ export function coreToOutRef(input: CML.TransactionInput): OutRef { }; } -export function coresToOutRefs(inputs: Array): OutRef[] { +export function coresToOutRefs(inputs: CML.TransactionInput[]): OutRef[] { const result: OutRef[] = []; for (let i = 0; i < inputs.length; i++) { result.push(coreToOutRef(inputs[i])); @@ -129,3 +128,68 @@ export function coresToTxOutputs(outputs: CML.TransactionOutput[]): TxOutput[] { // }); // return result; // } + +export const selectUTxOs = (utxos: UTxO[], totalAssets: Assets) => { + const selectedUtxos: UTxO[] = []; + let isSelected = false; + const assetsRequired = new Map(Object.entries(totalAssets)); + + //LargestFirstMultiAsset + const sortedUtxos = sortUTxOs(utxos); + + for (const utxo of sortedUtxos) { + isSelected = false; + + for (const [unit, amount] of assetsRequired) { + if (Object.hasOwn(utxo.assets, unit)) { + const utxoAmount = utxo.assets[unit]; + + if (utxoAmount >= amount) { + assetsRequired.delete(unit); + } else { + assetsRequired.set(unit, amount - utxoAmount); + } + + isSelected = true; + } + } + + if (isSelected) { + selectedUtxos.push(utxo); + } + if (assetsRequired.size == 0) { + break; + } + } + + if (assetsRequired.size > 0) return []; + + return selectedUtxos; +}; + +/** + * Sorts an array of UTXOs by the amount of "lovelace" in ascending or descending order. + * + * @param {UTxO[]} utxos - The array of UTXO objects to be sorted. + * @param {"ascending" | "descending"} [order="descending"] - The order in which to sort the UTXOs. + * Use "ascending" for ascending order and "descending" for descending order. + * @returns {UTxO[]} - The sorted array of UTXOs. + * + */ +export const sortUTxOs = ( + utxos: UTxO[], + order: "ascending" | "descending" = "descending", +): UTxO[] => { + return utxos.toSorted((a, b) => { + if (a.assets["lovelace"] > b.assets["lovelace"]) { + return order === "ascending" ? 1 : -1; + } + if (a.assets["lovelace"] < b.assets["lovelace"]) { + return order === "ascending" ? -1 : 1; + } + return 0; + }); +}; + +export const isEqualUTxO = (self: UTxO, that: UTxO) => + self.txHash === that.txHash && self.outputIndex === that.outputIndex; diff --git a/packages/utils/src/value.ts b/packages/utils/src/value.ts index d652d3f4..c586a2a2 100644 --- a/packages/utils/src/value.ts +++ b/packages/utils/src/value.ts @@ -1,13 +1,13 @@ import { Assets, PolicyId, Unit } from "@lucid-evolution/core-types"; -import { toText } from "@lucid-evolution/core-utils"; +import { fromText, toText } from "@lucid-evolution/core-utils"; import { CML } from "./core.js"; import { fromLabel, toLabel } from "./label.js"; export function valueToAssets(value: CML.Value): Assets { const assets: Assets = {}; assets["lovelace"] = value.coin(); - const ma = value.multi_asset(); - if (ma) { + if (value.has_multiassets()) { + const ma = value.multi_asset(); const multiAssets = ma.keys(); for (let j = 0; j < multiAssets.len(); j++) { const policy = multiAssets.get(j); @@ -16,7 +16,8 @@ export function valueToAssets(value: CML.Value): Assets { for (let k = 0; k < assetNames.len(); k++) { const policyAsset = assetNames.get(k); const quantity = policyAssets.get(policyAsset)!; - const unit = policy.to_hex() + policyAsset.to_cbor_hex(); + //FIX: report to dcspark policyAsset.to_cbor_hex() adds the head byte twice eg. MyMintedToken -> (to Hex) -> 4d4d794d696e746564546f6b656e (This is wrong) | expected Token Name -> 4d794d696e746564546f6b656e + const unit = policy.to_hex() + fromText(policyAsset.to_str()); assets[unit] = quantity; } }