From 837a8b783b9151406d7c94e58827beb6cb43a9bf Mon Sep 17 00:00:00 2001 From: William Wolff Date: Fri, 27 Oct 2023 20:00:50 +0200 Subject: [PATCH] feat: chaining of txs --- src/lucid/lucid.ts | 36 +++++++------- src/lucid/tx.ts | 22 +++++++-- src/lucid/tx_complete.ts | 71 +++++++++++++++++++++++++-- src/types/types.ts | 14 +++--- src/utils/utils.ts | 102 ++++++++++++++++++++++++++++++--------- tests/mod.test.ts | 86 ++++++++++++++++++++++++++++----- 6 files changed, 262 insertions(+), 69 deletions(-) diff --git a/src/lucid/lucid.ts b/src/lucid/lucid.ts index b886fbe2..bfd8d6bd 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, @@ -27,19 +21,25 @@ import { Slot, Transaction, TxHash, - Unit, UTxO, + Unit, Wallet, WalletApi, } from "../types/mod.ts"; +import { + Utils, + coreToUtxo, + createCostModels, + fromHex, + fromUnit, + paymentCredentialOf, + toHex, + toUnit, + 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..9b6c4336 100644 --- a/src/lucid/tx.ts +++ b/src/lucid/tx.ts @@ -29,7 +29,7 @@ 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 +37,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 +93,16 @@ 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 +558,12 @@ 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 +578,6 @@ export class Tx { ]), ); } - this.txBuilder.balance( changeAddress, (() => { @@ -602,6 +612,7 @@ export class Tx { })(), ); + const utxoSet = this.inputUTxOs ?? coresToUtxos(await this.lucid.wallet.getUtxosCore()); return new TxComplete( this.lucid, await this.txBuilder.construct( @@ -609,6 +620,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..60ed6ba3 100644 --- a/src/lucid/tx_complete.ts +++ b/src/lucid/tx_complete.ts @@ -1,27 +1,33 @@ 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 +117,63 @@ 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)); + } +} \ No newline at end of file diff --git a/src/types/types.ts b/src/types/types.ts index e9a031a6..198e6dfd 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -30,7 +30,7 @@ export interface Provider { /** Query UTxOs by address or payment credential filtered by a specific unit. */ getUtxosWithUnit( addressOrCredential: Address | Credential, - unit: Unit, + unit: Unit ): Promise; /** Query a UTxO by a unit. It needs to be an NFT (or optionally the entire supply in one UTxO). */ getUtxoByUnit(unit: Unit): Promise; @@ -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" @@ -180,7 +178,7 @@ export interface Wallet { signTx(tx: C.Transaction): Promise; signMessage( address: Address | RewardAddress, - payload: Payload, + payload: Payload ): Promise; submitTx(signedTx: Transaction): Promise; } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 414baee6..6261cf47 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, + UTxO, 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,79 @@ 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": @@ -737,4 +795,4 @@ export function addAssets(...assets: Assets[]): Assets { } return a; }, {}); -} +} \ No newline at end of file diff --git a/tests/mod.test.ts b/tests/mod.test.ts index cae88b9c..f224bf9f 100644 --- a/tests/mod.test.ts +++ b/tests/mod.test.ts @@ -1,31 +1,32 @@ +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, C, Constr, + Data, + Lucid, + MerkleTree, + PROTOCOL_PARAMETERS_DEFAULT, + SLOT_CONFIG_NETWORK, + assetsToValue, coreToUtxo, createCostModels, - Data, datumJsonToCbor, fromHex, fromLabel, fromUnit, - Lucid, - MerkleTree, - PROTOCOL_PARAMETERS_DEFAULT, - SLOT_CONFIG_NETWORK, toHex, toLabel, toUnit, utxoToCore, - valueToAssets, + 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,62 @@ 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()}`); +}); \ No newline at end of file