diff --git a/README.md b/README.md index 367266da..6e6ad42d 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,22 @@ const txHash = await signedTx.submit(); console.log(txHash); ``` +### Tx Chaining + +```js +const tx1 = await lucid.newTx() + .payToAddress("addr...", { lovelace: 5000000n }) + .complete(); + +const tx2 = tx1 + // select tx1 outputs that shall become inputs for tx2 + .chain((outputs) => outputs.filter(output => output.address === 'addr...')) + .payToAddress("addr...", { lovelace: 2500000n }) + .complete(); + +// sign tx1 & tx2, submit them +``` + ### Test ``` diff --git a/docs/docs/getting-started/choose-provider.md b/docs/docs/getting-started/choose-provider.md index 57e70046..920750a5 100644 --- a/docs/docs/getting-started/choose-provider.md +++ b/docs/docs/getting-started/choose-provider.md @@ -46,9 +46,9 @@ import { Lucid, Maestro } from "https://deno.land/x/lucid/mod.ts"; const lucid = await Lucid.new( new Maestro({ - network: "Preprod", // For MAINNET: "Mainnet". - apiKey: "", // Get yours by visiting https://docs.gomaestro.org/docs/Getting-started/Sign-up-login. - turboSubmit: false // Read about paid turbo transaction submission feature at https://docs.gomaestro.org/docs/Dapp%20Platform/Turbo%20Transaction. + network: "Preprod", // For MAINNET: "Mainnet". + apiKey: "", // Get yours by visiting https://docs.gomaestro.org/docs/Getting-started/Sign-up-login. + turboSubmit: false, // Read about paid turbo transaction submission feature at https://docs.gomaestro.org/docs/Dapp%20Platform/Turbo%20Transaction. }), "Preprod", // For MAINNET: "Mainnet". ); diff --git a/src/lucid/lucid.ts b/src/lucid/lucid.ts index b886fbe2..5b2d418a 100644 --- a/src/lucid/lucid.ts +++ b/src/lucid/lucid.ts @@ -1,15 +1,9 @@ import { C } from "../core/mod.ts"; -import { - coreToUtxo, - createCostModels, - fromHex, - fromUnit, - paymentCredentialOf, - toHex, - toUnit, - Utils, - utxoToCore, -} from "../utils/mod.ts"; +import { signData, verifyData } from "../misc/sign_data.ts"; +import { discoverOwnUsedTxKeyHashes, walletFromSeed } from "../misc/wallet.ts"; +import { Constr, Data } from "../plutus/data.ts"; +import { SLOT_CONFIG_NETWORK } from "../plutus/time.ts"; +import { Emulator } from "../provider/emulator.ts"; import { Address, Credential, @@ -32,14 +26,20 @@ import { Wallet, WalletApi, } from "../types/mod.ts"; +import { + coreToUtxo, + createCostModels, + fromHex, + fromUnit, + paymentCredentialOf, + toHex, + toUnit, + Utils, + utxoToCore, +} from "../utils/mod.ts"; +import { Message } from "./message.ts"; import { Tx } from "./tx.ts"; import { TxComplete } from "./tx_complete.ts"; -import { discoverOwnUsedTxKeyHashes, walletFromSeed } from "../misc/wallet.ts"; -import { signData, verifyData } from "../misc/sign_data.ts"; -import { Message } from "./message.ts"; -import { SLOT_CONFIG_NETWORK } from "../plutus/time.ts"; -import { Constr, Data } from "../plutus/data.ts"; -import { Emulator } from "../provider/emulator.ts"; export class Lucid { txBuilderConfig!: C.TransactionBuilderConfig; diff --git a/src/lucid/tx.ts b/src/lucid/tx.ts index 3c1f06b2..e9a4bea0 100644 --- a/src/lucid/tx.ts +++ b/src/lucid/tx.ts @@ -29,7 +29,11 @@ import { toScriptRef, utxoToCore, } from "../utils/mod.ts"; -import { applyDoubleCborEncoding } from "../utils/utils.ts"; +import { + applyDoubleCborEncoding, + coresToUtxos, + utxosToCores, +} from "../utils/utils.ts"; import { Lucid } from "./lucid.ts"; import { TxComplete } from "./tx_complete.ts"; @@ -37,7 +41,9 @@ export class Tx { txBuilder: C.TransactionBuilder; /** Stores the tx instructions, which get executed after calling .complete() */ private tasks: ((that: Tx) => unknown)[]; - private lucid: Lucid; + protected lucid: Lucid; + /** Stores the available input utxo set for this tx (for tx chaining), if undefined falls back to wallet utxos */ + private inputUTxOs?: UTxO[]; constructor(lucid: Lucid) { this.lucid = lucid; @@ -91,6 +97,18 @@ export class Tx { return this; } + /** + * Defines the set of UTxOs that is considered as inputs for balancing this transactions. + * If not set explicitely, falls back to the wallet's UTxO set. + */ + collectTxInputsFrom(utxos: UTxO[]): Tx { + // NOTE: merge exisitng input utxos to support tx composition + this.tasks.push((tx) => + tx.inputUTxOs = [...(tx.inputUTxOs ?? []), ...utxos] + ); + return this; + } + /** * All assets should be of the same policy id. * You can chain mintAssets functions together if you need to mint assets with different policy ids. @@ -546,13 +564,14 @@ export class Tx { task = this.tasks.shift(); } - const utxos = await this.lucid.wallet.getUtxosCore(); + const utxos = this.inputUTxOs !== undefined + ? utxosToCores(this.inputUTxOs) + : await this.lucid.wallet.getUtxosCore(); const changeAddress: C.Address = addressFromWithNetworkCheck( options?.change?.address || (await this.lucid.wallet.address()), this.lucid, ); - if (options?.coinSelection || options?.coinSelection === undefined) { this.txBuilder.add_inputs_from( utxos, @@ -567,7 +586,6 @@ export class Tx { ]), ); } - this.txBuilder.balance( changeAddress, (() => { @@ -602,6 +620,8 @@ export class Tx { })(), ); + const utxoSet = this.inputUTxOs ?? + coresToUtxos(await this.lucid.wallet.getUtxosCore()); return new TxComplete( this.lucid, await this.txBuilder.construct( @@ -609,6 +629,7 @@ export class Tx { changeAddress, options?.nativeUplc === undefined ? true : options?.nativeUplc, ), + utxoSet, ); } diff --git a/src/lucid/tx_complete.ts b/src/lucid/tx_complete.ts index 1566b4dd..45f81a99 100644 --- a/src/lucid/tx_complete.ts +++ b/src/lucid/tx_complete.ts @@ -1,27 +1,40 @@ import { C } from "../core/mod.ts"; import { + Credential, PrivateKey, Transaction, TransactionWitnesses, TxHash, + UTxO, } from "../types/mod.ts"; +import { + coresToOutRefs, + fromHex, + getAddressDetails, + paymentCredentialOf, + producedUtxosFrom, + toHex, +} from "../utils/mod.ts"; import { Lucid } from "./lucid.ts"; +import { Tx } from "./tx.ts"; import { TxSigned } from "./tx_signed.ts"; -import { fromHex, toHex } from "../utils/mod.ts"; export class TxComplete { txComplete: C.Transaction; witnessSetBuilder: C.TransactionWitnessSetBuilder; private tasks: (() => Promise)[]; + /** Stores the available input utxo set for this tx (for tx chaining), if undefined falls back to wallet utxos */ + private utxos?: UTxO[]; private lucid: Lucid; fee: number; exUnits: { cpu: number; mem: number } | null = null; - constructor(lucid: Lucid, tx: C.Transaction) { + constructor(lucid: Lucid, tx: C.Transaction, utxos?: UTxO[]) { this.lucid = lucid; this.txComplete = tx; this.witnessSetBuilder = C.TransactionWitnessSetBuilder.new(); this.tasks = []; + this.utxos = utxos; this.fee = parseInt(tx.body().fee().to_str()); const redeemers = tx.witness_set().redeemers(); @@ -111,4 +124,82 @@ export class TxComplete { toHash(): TxHash { return C.hash_transaction(this.txComplete.body()).to_hex(); } + + /** + * This function provides access to the produced outputs of the current transaction + * that can be selectively picked to be chained with a new transaction which is returned + * as result. + * + * @param outputChainSelector provides the tx outputs of the transaction that can be used for chaining a new tx. + * If undefined is returned from this function, all outputs that are spendable from this wallet are chained. + * @param redeemer this arguments is expected to match the number of selected chained outputs from the first argument and can be used + * to chain script outputs with specific redeemers. + * @returns a new transaction that already has inputs set defined by the *outputChainSelector* function. + */ + chain( + outputChainSelector: (utxos: UTxO[]) => UTxO | UTxO[] | undefined, + redeemer?: string | string[] | undefined, + ): Tx { + const txOutputs = producedUtxosFrom(this); + let chainedOutputs = outputChainSelector(txOutputs); + const inputUTxOs = this.getUpdatedInputUTxOs(this.utxos); + const chainedTx = this.lucid + .newTx() + .collectTxInputsFrom(inputUTxOs); + + if ( + !chainedOutputs || + Array.isArray(chainedOutputs) && chainedOutputs.length === 0 + ) { + // chain all spendable unspent transaction outputs + chainedOutputs = inputUTxOs; + } + + if (Array.isArray(chainedOutputs) && Array.isArray(redeemer)) { + if (!redeemer || chainedOutputs.length === redeemer.length) { + chainedOutputs.forEach((utxo, i) => + chainedTx.collectFrom([utxo], redeemer.at(i)) + ); + } else { + throw new Error( + `Mismatching number of chained outputs (${chainedOutputs.length}) & redeemers (${redeemer.length})`, + ); + } + } else if (!Array.isArray(chainedOutputs) && !Array.isArray(redeemer)) { + chainedTx.collectFrom([chainedOutputs], redeemer); + } else { + throw new Error( + "Mismatching types for provided chained output(s) and redeemer(s).", + ); + } + return chainedTx; + } + + private getUpdatedInputUTxOs( + inputUTxOs?: UTxO[], + ): UTxO[] { + if (!inputUTxOs) return []; + const paymentCredentials = inputUTxOs.map(({ address }) => + paymentCredentialOf(address) + ); + const consumedOutRefs = coresToOutRefs(this.txComplete.body().inputs()); + const isSpendableByCreds = + (walletPaymentCredentials: Credential[]) => ({ address }: UTxO) => + walletPaymentCredentials.find(({ hash: walletPKeyHash }) => { + const { paymentCredential: outputPayCred } = getAddressDetails( + address, + ); + return (outputPayCred && walletPKeyHash === outputPayCred.hash && + outputPayCred.type === "Key"); + }) !== undefined; + const producedUtxos = producedUtxosFrom(this); + const isNotConsumed = ({ txHash, outputIndex }: UTxO) => + consumedOutRefs.find((outRef) => + outRef.txHash === txHash && outRef.outputIndex === outputIndex + ) === undefined; + const isSpendable = isSpendableByCreds(paymentCredentials); + return inputUTxOs.filter(isNotConsumed).concat( + producedUtxos.filter(isSpendable), + ); + } } diff --git a/src/types/types.ts b/src/types/types.ts index e9a031a6..a43155c5 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -125,18 +125,16 @@ export type ScriptRef = string; /** Hex */ export type Payload = string; -export type UTxO = { - txHash: TxHash; - outputIndex: number; - assets: Assets; +export type UTxO = OutRef & TxOutput; +export type OutRef = { txHash: TxHash; outputIndex: number }; +export type TxOutput = { address: Address; + assets: Assets; datumHash?: DatumHash | null; datum?: Datum | null; scriptRef?: Script | null; }; -export type OutRef = { txHash: TxHash; outputIndex: number }; - export type AddressType = | "Base" | "Enterprise" diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 414baee6..acb438ec 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -4,6 +4,15 @@ import { encodeToString, } from "https://deno.land/std@0.100.0/encoding/hex.ts"; import { C } from "../core/mod.ts"; +import { Lucid, TxComplete } from "../lucid/mod.ts"; +import { generateMnemonic } from "../misc/bip39.ts"; +import { crc8 } from "../misc/crc8.ts"; +import { Data } from "../plutus/data.ts"; +import { + SLOT_CONFIG_NETWORK, + slotToBeginUnixTime, + unixTimeToEnclosingSlot, +} from "../plutus/time.ts"; import { Address, AddressDetails, @@ -17,6 +26,7 @@ import { MintingPolicy, NativeScript, Network, + OutRef, PolicyId, PrivateKey, PublicKey, @@ -25,21 +35,13 @@ import { ScriptHash, Slot, SpendingValidator, + TxOutput, Unit, UnixTime, UTxO, Validator, WithdrawalValidator, } from "../types/mod.ts"; -import { Lucid } from "../lucid/mod.ts"; -import { generateMnemonic } from "../misc/bip39.ts"; -import { crc8 } from "../misc/crc8.ts"; -import { - SLOT_CONFIG_NETWORK, - slotToBeginUnixTime, - unixTimeToEnclosingSlot, -} from "../plutus/time.ts"; -import { Data } from "../plutus/data.ts"; export class Utils { private lucid: Lucid; @@ -565,23 +567,78 @@ export function utxoToCore(utxo: UTxO): C.TransactionUnspentOutput { output, ); } +export function utxosToCores(utxos: UTxO[]): C.TransactionUnspentOutputs { + const result = C.TransactionUnspentOutputs.new(); + utxos.map(utxoToCore).forEach((utxo) => result.add(utxo)); + return result; +} export function coreToUtxo(coreUtxo: C.TransactionUnspentOutput): UTxO { return { - txHash: toHex(coreUtxo.input().transaction_id().to_bytes()), - outputIndex: parseInt(coreUtxo.input().index().to_str()), - assets: valueToAssets(coreUtxo.output().amount()), - address: coreUtxo.output().address().as_byron() - ? coreUtxo.output().address().as_byron()?.to_base58()! - : coreUtxo.output().address().to_bech32(undefined), - datumHash: coreUtxo.output()?.datum()?.as_data_hash()?.to_hex(), - datum: coreUtxo.output()?.datum()?.as_data() && - toHex(coreUtxo.output().datum()!.as_data()!.get().to_bytes()), - scriptRef: coreUtxo.output()?.script_ref() && - fromScriptRef(coreUtxo.output().script_ref()!), + ...coreToOutRef(coreUtxo.input()), + ...coreToTxOutput(coreUtxo.output()), }; } +export function coresToUtxos(utxos: C.TransactionUnspentOutputs): UTxO[] { + const result: UTxO[] = []; + for (let i = 0; i < utxos.len(); i++) { + result.push(coreToUtxo(utxos.get(i))); + } + return result; +} + +export function coreToOutRef(input: C.TransactionInput): OutRef { + return { + txHash: toHex(input.transaction_id().to_bytes()), + outputIndex: parseInt(input.index().to_str()), + }; +} + +export function coresToOutRefs(inputs: C.TransactionInputs): OutRef[] { + const result: OutRef[] = []; + for (let i = 0; i < inputs.len(); i++) { + result.push(coreToOutRef(inputs.get(i))); + } + return result; +} + +export function coreToTxOutput(output: C.TransactionOutput): TxOutput { + return { + assets: valueToAssets(output.amount()), + address: output.address().as_byron() + ? output.address().as_byron()?.to_base58()! + : output.address().to_bech32(undefined), + datumHash: output.datum()?.as_data_hash()?.to_hex(), + datum: output.datum()?.as_data() && + toHex(output.datum()!.as_data()!.get().to_bytes()), + scriptRef: output.script_ref() && fromScriptRef(output.script_ref()!), + }; +} + +export function coresToTxOutputs(outputs: C.TransactionOutputs): TxOutput[] { + const result: TxOutput[] = []; + for (let i = 0; i < outputs.len(); i++) { + result.push(coreToTxOutput(outputs.get(i))); + } + return result; +} + +export function producedUtxosFrom(unsignedTx: TxComplete): UTxO[] { + const result: UTxO[] = []; + const hash = unsignedTx.toHash(); + coresToTxOutputs(unsignedTx.txComplete.body().outputs()).forEach( + (output, index) => { + result.push({ + outputIndex: index, + txHash: hash, + ...output, + }); + }, + ); + return result; +} + export function networkToId(network: Network): number { switch (network) { case "Preview": diff --git a/tests/mod.test.ts b/tests/mod.test.ts index cae88b9c..ab7c9e97 100644 --- a/tests/mod.test.ts +++ b/tests/mod.test.ts @@ -1,3 +1,9 @@ +import { + assert, + assertEquals, + assertNotEquals, +} from "https://deno.land/std@0.145.0/testing/asserts.ts"; +import * as fc from "https://esm.sh/fast-check@3.1.1"; import { Assets, assetsToValue, @@ -20,12 +26,7 @@ import { utxoToCore, valueToAssets, } from "../src/mod.ts"; -import { - assert, - assertEquals, - assertNotEquals, -} from "https://deno.land/std@0.145.0/testing/asserts.ts"; -import * as fc from "https://esm.sh/fast-check@3.1.1"; +import { coresToTxOutputs } from "../src/utils/utils.ts"; const privateKey = C.PrivateKey.generate_ed25519().to_bech32(); const lucid = await Lucid.new(undefined, "Preprod"); @@ -484,3 +485,126 @@ Deno.test("Preserve task/transaction order", async () => { assertEquals(num, outputNum); }); }); + +Deno.test("chain value transactions", async () => { + lucid.selectWalletFrom({ + address: + "addr_test1qq90qrxyw5qtkex0l7mc86xy9a6xkn5t3fcwm6wq33c38t8nhh356yzp7k3qwmhe4fk0g5u6kx5ka4rz5qcq4j7mvh2sts2cfa", + utxos: [ + { + txHash: + "222fc93bc0dda80e78890f1f965733239e1f64f76555e8dcde1a4aa7db67b129", + outputIndex: 0, + assets: { lovelace: 1_100_000n }, + address: + "addr_test1qq90qrxyw5qtkex0l7mc86xy9a6xkn5t3fcwm6wq33c38t8nhh356yzp7k3qwmhe4fk0g5u6kx5ka4rz5qcq4j7mvh2sts2cfa", + datumHash: null, + datum: null, + scriptRef: null, + }, + { + txHash: + "111fc93bc0dda80e78890f1f965733239e1f64f76555e8dcde1a4aa7db67b129", + outputIndex: 0, + assets: { lovelace: 10_000_000n }, + address: + "addr_test1qq90qrxyw5qtkex0l7mc86xy9a6xkn5t3fcwm6wq33c38t8nhh356yzp7k3qwmhe4fk0g5u6kx5ka4rz5qcq4j7mvh2sts2cfa", + datumHash: null, + datum: null, + scriptRef: null, + }, + ], + }); + + const tx1 = await lucid.newTx() + .payToAddress( + "addr_test1qrqcwuw9ju33z2l0zayt38wsthsldyrgyt82p2p3trccucffejwnp8afwa8v58aw7dpj7hpf9dh8txr0qlksqtcsxheqhekxra", + { lovelace: 2_000_000n }, + ) + .complete(); + + const tx1Outs = coresToTxOutputs(tx1.txComplete.body().outputs()); + assertEquals(2, tx1Outs.length, "Expected 2 tx outputs for tx1"); + assertEquals( + 2_000_000n, + tx1Outs.at(0)!.assets.lovelace, + `Expected 2 ADA pay out as defined. Actual ${ + tx1Outs.at(0)!.assets.lovelace + }`, + ); + const tx2 = await tx1 + .chain((utxos) => + utxos.find(({ address }) => + address === + "addr_test1qq90qrxyw5qtkex0l7mc86xy9a6xkn5t3fcwm6wq33c38t8nhh356yzp7k3qwmhe4fk0g5u6kx5ka4rz5qcq4j7mvh2sts2cfa" + )! + ) + .payToAddress( + "addr_test1qqnjvrr0rph6uylhucl58shxgpu64kmzpulqccmqdeds5ht72xn7hte3evkx34mg0dlulhzc9suyczrfnv9e4m95d22q3ma4ud", + { lovelace: 2_000_000n }, + ) + .payToAddress( + "addr_test1qqnjvrr0rph6uylhucl58shxgpu64kmzpulqccmqdeds5ht72xn7hte3evkx34mg0dlulhzc9suyczrfnv9e4m95d22q3ma4ud", + { lovelace: 1_000_000n }, + ) + .payToAddress( + "addr_test1qq90qrxyw5qtkex0l7mc86xy9a6xkn5t3fcwm6wq33c38t8nhh356yzp7k3qwmhe4fk0g5u6kx5ka4rz5qcq4j7mvh2sts2cfa", + { lovelace: 2_000_000n }, + ) + .complete(); + + assertEquals( + 1, + tx2.txComplete.body().inputs().len(), + `Expected 1 tx input for tx2. Actual ${tx2.txComplete.body().inputs().len()}`, + ); + const tx2Outs = coresToTxOutputs(tx2.txComplete.body().outputs()); + assertEquals( + 4, + tx2Outs.length, + `Expected 4 tx outputs for tx2. Actual ${tx2Outs.length}`, + ); + assertEquals( + 2_000_000n, + tx2Outs.at(0)!.assets.lovelace, + `Expected 2 ADA first tx output for tx2. Actual ${ + tx2Outs.at(0)!.assets.lovelace + }`, + ); + assertEquals( + 1_000_000n, + tx2Outs.at(1)!.assets.lovelace, + `Expected 1 ADA second tx output for tx2. Actual ${ + tx2Outs.at(1)!.assets.lovelace + }`, + ); + assertEquals( + 2_000_000n, + tx2Outs.at(2)!.assets.lovelace, + `Expected 2 ADA second tx output for tx2. Actual ${ + tx2Outs.at(2)!.assets.lovelace + }`, + ); + + const tx3 = await tx2 + .chain((utxos) => + utxos.find(({ address }) => + address === + "addr_test1qqnjvrr0rph6uylhucl58shxgpu64kmzpulqccmqdeds5ht72xn7hte3evkx34mg0dlulhzc9suyczrfnv9e4m95d22q3ma4ud" + )! + ) + .payToAddress( + "addr_test1qqnjvrr0rph6uylhucl58shxgpu64kmzpulqccmqdeds5ht72xn7hte3evkx34mg0dlulhzc9suyczrfnv9e4m95d22q3ma4ud", + { lovelace: 1_000_000n }, + ) + .payToAddress( + "addr_test1qrqcwuw9ju33z2l0zayt38wsthsldyrgyt82p2p3trccucffejwnp8afwa8v58aw7dpj7hpf9dh8txr0qlksqtcsxheqhekxra", + { lovelace: 1_000_000n }, + ) + .complete(); + assertEquals( + 2, + tx3.txComplete.body().inputs().len(), + `Expected 1 tx input for tx3. Actual ${tx3.txComplete.body().inputs().len()}`, + ); +});