From 6cce89210d6af8df6cfa5f0cde1e60ca7e5c1276 Mon Sep 17 00:00:00 2001 From: Angel Castillo Date: Tue, 21 May 2024 13:06:51 +0800 Subject: [PATCH 1/3] test(e2e): set local network cost models to the same values as preprod and mainnet --- .../babbage/alonzo-babbage-test-genesis.json | 308 +++++++++--------- 1 file changed, 154 insertions(+), 154 deletions(-) diff --git a/packages/e2e/local-network/templates/babbage/alonzo-babbage-test-genesis.json b/packages/e2e/local-network/templates/babbage/alonzo-babbage-test-genesis.json index c0dfd45440d..2b56b787c26 100644 --- a/packages/e2e/local-network/templates/babbage/alonzo-babbage-test-genesis.json +++ b/packages/e2e/local-network/templates/babbage/alonzo-babbage-test-genesis.json @@ -24,172 +24,172 @@ "maxCollateralInputs": 3, "costModels": { "PlutusV1": { - "sha2_256-memory-arguments": 4, - "equalsString-cpu-arguments-constant": 1000, - "cekDelayCost-exBudgetMemory": 100, - "lessThanEqualsByteString-cpu-arguments-intercept": 103599, - "divideInteger-memory-arguments-minimum": 1, - "appendByteString-cpu-arguments-slope": 621, - "blake2b-cpu-arguments-slope": 29175, - "iData-cpu-arguments": 150000, - "encodeUtf8-cpu-arguments-slope": 1000, - "unBData-cpu-arguments": 150000, - "multiplyInteger-cpu-arguments-intercept": 61516, + "addInteger-cpu-arguments-intercept": 205665, + "addInteger-cpu-arguments-slope": 812, + "addInteger-memory-arguments-intercept": 1, + "addInteger-memory-arguments-slope": 1, + "appendByteString-cpu-arguments-intercept": 1000, + "appendByteString-cpu-arguments-slope": 571, + "appendByteString-memory-arguments-intercept": 0, + "appendByteString-memory-arguments-slope": 1, + "appendString-cpu-arguments-intercept": 1000, + "appendString-cpu-arguments-slope": 24177, + "appendString-memory-arguments-intercept": 4, + "appendString-memory-arguments-slope": 1, + "bData-cpu-arguments": 1000, + "bData-memory-arguments": 32, + "blake2b_256-cpu-arguments-intercept": 117366, + "blake2b_256-cpu-arguments-slope": 10475, + "blake2b_256-memory-arguments": 4, + "cekApplyCost-exBudgetCPU": 23000, + "cekApplyCost-exBudgetMemory": 100, + "cekBuiltinCost-exBudgetCPU": 23000, + "cekBuiltinCost-exBudgetMemory": 100, + "cekConstCost-exBudgetCPU": 23000, "cekConstCost-exBudgetMemory": 100, - "nullList-cpu-arguments": 150000, - "equalsString-cpu-arguments-intercept": 150000, - "trace-cpu-arguments": 150000, - "mkNilData-memory-arguments": 32, - "lengthOfByteString-cpu-arguments": 150000, - "cekBuiltinCost-exBudgetCPU": 29773, - "bData-cpu-arguments": 150000, - "subtractInteger-cpu-arguments-slope": 0, - "unIData-cpu-arguments": 150000, - "consByteString-memory-arguments-intercept": 0, - "divideInteger-memory-arguments-slope": 1, - "divideInteger-cpu-arguments-model-arguments-slope": 118, - "listData-cpu-arguments": 150000, - "headList-cpu-arguments": 150000, + "cekDelayCost-exBudgetCPU": 23000, + "cekDelayCost-exBudgetMemory": 100, + "cekForceCost-exBudgetCPU": 23000, + "cekForceCost-exBudgetMemory": 100, + "cekLamCost-exBudgetCPU": 23000, + "cekLamCost-exBudgetMemory": 100, + "cekStartupCost-exBudgetCPU": 100, + "cekStartupCost-exBudgetMemory": 100, + "cekVarCost-exBudgetCPU": 23000, + "cekVarCost-exBudgetMemory": 100, + "chooseData-cpu-arguments": 19537, "chooseData-memory-arguments": 32, - "equalsInteger-cpu-arguments-intercept": 136542, - "sha3_256-cpu-arguments-slope": 82363, - "sliceByteString-cpu-arguments-slope": 5000, - "unMapData-cpu-arguments": 150000, - "lessThanInteger-cpu-arguments-intercept": 179690, - "mkCons-cpu-arguments": 150000, - "appendString-memory-arguments-intercept": 0, - "modInteger-cpu-arguments-model-arguments-slope": 118, - "ifThenElse-cpu-arguments": 1, - "mkNilPairData-cpu-arguments": 150000, - "lessThanEqualsInteger-cpu-arguments-intercept": 145276, - "addInteger-memory-arguments-slope": 1, + "chooseList-cpu-arguments": 175354, "chooseList-memory-arguments": 32, + "chooseUnit-cpu-arguments": 46417, + "chooseUnit-memory-arguments": 4, + "consByteString-cpu-arguments-intercept": 221973, + "consByteString-cpu-arguments-slope": 511, + "consByteString-memory-arguments-intercept": 0, + "consByteString-memory-arguments-slope": 1, + "constrData-cpu-arguments": 89141, "constrData-memory-arguments": 32, - "decodeUtf8-cpu-arguments-intercept": 150000, + "decodeUtf8-cpu-arguments-intercept": 497525, + "decodeUtf8-cpu-arguments-slope": 14068, + "decodeUtf8-memory-arguments-intercept": 4, + "decodeUtf8-memory-arguments-slope": 2, + "divideInteger-cpu-arguments-constant": 196500, + "divideInteger-cpu-arguments-model-arguments-intercept": 453240, + "divideInteger-cpu-arguments-model-arguments-slope": 220, + "divideInteger-memory-arguments-intercept": 0, + "divideInteger-memory-arguments-minimum": 1, + "divideInteger-memory-arguments-slope": 1, + "encodeUtf8-cpu-arguments-intercept": 1000, + "encodeUtf8-cpu-arguments-slope": 28662, + "encodeUtf8-memory-arguments-intercept": 4, + "encodeUtf8-memory-arguments-slope": 2, + "equalsByteString-cpu-arguments-constant": 245000, + "equalsByteString-cpu-arguments-intercept": 216773, + "equalsByteString-cpu-arguments-slope": 62, + "equalsByteString-memory-arguments": 1, + "equalsData-cpu-arguments-intercept": 1060367, + "equalsData-cpu-arguments-slope": 12586, "equalsData-memory-arguments": 1, - "subtractInteger-memory-arguments-slope": 1, - "appendByteString-memory-arguments-intercept": 0, - "lengthOfByteString-memory-arguments": 4, - "headList-memory-arguments": 32, - "listData-memory-arguments": 32, - "consByteString-cpu-arguments-intercept": 150000, - "unIData-memory-arguments": 32, - "remainderInteger-memory-arguments-minimum": 1, - "bData-memory-arguments": 32, - "lessThanByteString-cpu-arguments-slope": 248, - "encodeUtf8-memory-arguments-intercept": 0, - "cekStartupCost-exBudgetCPU": 100, - "multiplyInteger-memory-arguments-intercept": 0, - "unListData-memory-arguments": 32, - "remainderInteger-cpu-arguments-model-arguments-slope": 118, - "cekVarCost-exBudgetCPU": 29773, - "remainderInteger-memory-arguments-slope": 1, - "cekForceCost-exBudgetCPU": 29773, - "sha2_256-cpu-arguments-slope": 29175, + "equalsInteger-cpu-arguments-intercept": 208512, + "equalsInteger-cpu-arguments-slope": 421, "equalsInteger-memory-arguments": 1, - "indexByteString-memory-arguments": 1, - "addInteger-memory-arguments-intercept": 1, - "chooseUnit-cpu-arguments": 150000, - "sndPair-cpu-arguments": 150000, - "cekLamCost-exBudgetCPU": 29773, - "fstPair-cpu-arguments": 150000, - "quotientInteger-memory-arguments-minimum": 1, - "decodeUtf8-cpu-arguments-slope": 1000, - "lessThanInteger-memory-arguments": 1, - "lessThanEqualsInteger-cpu-arguments-slope": 1366, + "equalsString-cpu-arguments-constant": 187000, + "equalsString-cpu-arguments-intercept": 1000, + "equalsString-cpu-arguments-slope": 52998, + "equalsString-memory-arguments": 1, + "fstPair-cpu-arguments": 80436, "fstPair-memory-arguments": 32, - "modInteger-memory-arguments-intercept": 0, - "unConstrData-cpu-arguments": 150000, - "lessThanEqualsInteger-memory-arguments": 1, - "chooseUnit-memory-arguments": 32, - "sndPair-memory-arguments": 32, - "addInteger-cpu-arguments-intercept": 197209, - "decodeUtf8-memory-arguments-slope": 8, - "equalsData-cpu-arguments-intercept": 150000, - "mapData-cpu-arguments": 150000, - "mkPairData-cpu-arguments": 150000, - "quotientInteger-cpu-arguments-constant": 148000, - "consByteString-memory-arguments-slope": 1, - "cekVarCost-exBudgetMemory": 100, - "indexByteString-cpu-arguments": 150000, - "unListData-cpu-arguments": 150000, - "equalsInteger-cpu-arguments-slope": 1326, - "cekStartupCost-exBudgetMemory": 100, - "subtractInteger-cpu-arguments-intercept": 197209, - "divideInteger-cpu-arguments-model-arguments-intercept": 425507, - "divideInteger-memory-arguments-intercept": 0, - "cekForceCost-exBudgetMemory": 100, - "blake2b-cpu-arguments-intercept": 2477736, - "remainderInteger-cpu-arguments-constant": 148000, - "tailList-cpu-arguments": 150000, - "encodeUtf8-cpu-arguments-intercept": 150000, - "equalsString-cpu-arguments-slope": 1000, + "headList-cpu-arguments": 43249, + "headList-memory-arguments": 32, + "iData-cpu-arguments": 1000, + "iData-memory-arguments": 32, + "ifThenElse-cpu-arguments": 80556, + "ifThenElse-memory-arguments": 1, + "indexByteString-cpu-arguments": 57667, + "indexByteString-memory-arguments": 4, + "lengthOfByteString-cpu-arguments": 1000, + "lengthOfByteString-memory-arguments": 10, + "lessThanByteString-cpu-arguments-intercept": 197145, + "lessThanByteString-cpu-arguments-slope": 156, "lessThanByteString-memory-arguments": 1, - "multiplyInteger-cpu-arguments-slope": 11218, - "appendByteString-cpu-arguments-intercept": 396231, - "lessThanEqualsByteString-cpu-arguments-slope": 248, - "modInteger-memory-arguments-slope": 1, - "addInteger-cpu-arguments-slope": 0, - "equalsData-cpu-arguments-slope": 10000, - "decodeUtf8-memory-arguments-intercept": 0, - "chooseList-cpu-arguments": 150000, - "constrData-cpu-arguments": 150000, - "equalsByteString-memory-arguments": 1, - "cekApplyCost-exBudgetCPU": 29773, - "quotientInteger-memory-arguments-slope": 1, - "verifySignature-cpu-arguments-intercept": 3345831, - "unMapData-memory-arguments": 32, + "lessThanEqualsByteString-cpu-arguments-intercept": 197145, + "lessThanEqualsByteString-cpu-arguments-slope": 156, + "lessThanEqualsByteString-memory-arguments": 1, + "lessThanEqualsInteger-cpu-arguments-intercept": 204924, + "lessThanEqualsInteger-cpu-arguments-slope": 473, + "lessThanEqualsInteger-memory-arguments": 1, + "lessThanInteger-cpu-arguments-intercept": 208896, + "lessThanInteger-cpu-arguments-slope": 511, + "lessThanInteger-memory-arguments": 1, + "listData-cpu-arguments": 52467, + "listData-memory-arguments": 32, + "mapData-cpu-arguments": 64832, + "mapData-memory-arguments": 32, + "mkCons-cpu-arguments": 65493, "mkCons-memory-arguments": 32, - "sliceByteString-memory-arguments-slope": 1, - "sha3_256-memory-arguments": 4, - "ifThenElse-memory-arguments": 1, + "mkNilData-cpu-arguments": 22558, + "mkNilData-memory-arguments": 32, + "mkNilPairData-cpu-arguments": 16563, "mkNilPairData-memory-arguments": 32, - "equalsByteString-cpu-arguments-slope": 247, - "appendString-cpu-arguments-intercept": 150000, - "quotientInteger-cpu-arguments-model-arguments-slope": 118, - "cekApplyCost-exBudgetMemory": 100, - "equalsString-memory-arguments": 1, + "mkPairData-cpu-arguments": 76511, + "mkPairData-memory-arguments": 32, + "modInteger-cpu-arguments-constant": 196500, + "modInteger-cpu-arguments-model-arguments-intercept": 453240, + "modInteger-cpu-arguments-model-arguments-slope": 220, + "modInteger-memory-arguments-intercept": 0, + "modInteger-memory-arguments-minimum": 1, + "modInteger-memory-arguments-slope": 1, + "multiplyInteger-cpu-arguments-intercept": 69522, + "multiplyInteger-cpu-arguments-slope": 11687, + "multiplyInteger-memory-arguments-intercept": 0, "multiplyInteger-memory-arguments-slope": 1, - "cekBuiltinCost-exBudgetMemory": 100, - "remainderInteger-memory-arguments-intercept": 0, - "sha2_256-cpu-arguments-intercept": 2477736, - "remainderInteger-cpu-arguments-model-arguments-intercept": 425507, - "lessThanEqualsByteString-memory-arguments": 1, - "tailList-memory-arguments": 32, - "mkNilData-cpu-arguments": 150000, - "chooseData-cpu-arguments": 150000, - "unBData-memory-arguments": 32, - "blake2b-memory-arguments": 4, - "iData-memory-arguments": 32, + "nullList-cpu-arguments": 60091, "nullList-memory-arguments": 32, - "cekDelayCost-exBudgetCPU": 29773, + "quotientInteger-cpu-arguments-constant": 196500, + "quotientInteger-cpu-arguments-model-arguments-intercept": 453240, + "quotientInteger-cpu-arguments-model-arguments-slope": 220, + "quotientInteger-memory-arguments-intercept": 0, + "quotientInteger-memory-arguments-minimum": 1, + "quotientInteger-memory-arguments-slope": 1, + "remainderInteger-cpu-arguments-constant": 196500, + "remainderInteger-cpu-arguments-model-arguments-intercept": 453240, + "remainderInteger-cpu-arguments-model-arguments-slope": 220, + "remainderInteger-memory-arguments-intercept": 0, + "remainderInteger-memory-arguments-minimum": 1, + "remainderInteger-memory-arguments-slope": 1, + "sha2_256-cpu-arguments-intercept": 806990, + "sha2_256-cpu-arguments-slope": 30482, + "sha2_256-memory-arguments": 4, + "sha3_256-cpu-arguments-intercept": 1927926, + "sha3_256-cpu-arguments-slope": 82523, + "sha3_256-memory-arguments": 4, + "sliceByteString-cpu-arguments-intercept": 265318, + "sliceByteString-cpu-arguments-slope": 0, + "sliceByteString-memory-arguments-intercept": 4, + "sliceByteString-memory-arguments-slope": 0, + "sndPair-cpu-arguments": 85931, + "sndPair-memory-arguments": 32, + "subtractInteger-cpu-arguments-intercept": 205665, + "subtractInteger-cpu-arguments-slope": 812, "subtractInteger-memory-arguments-intercept": 1, - "lessThanByteString-cpu-arguments-intercept": 103599, - "consByteString-cpu-arguments-slope": 1000, - "appendByteString-memory-arguments-slope": 1, + "subtractInteger-memory-arguments-slope": 1, + "tailList-cpu-arguments": 41182, + "tailList-memory-arguments": 32, + "trace-cpu-arguments": 212342, "trace-memory-arguments": 32, - "divideInteger-cpu-arguments-constant": 148000, - "cekConstCost-exBudgetCPU": 29773, - "encodeUtf8-memory-arguments-slope": 8, - "quotientInteger-cpu-arguments-model-arguments-intercept": 425507, - "mapData-memory-arguments": 32, - "appendString-cpu-arguments-slope": 1000, - "modInteger-cpu-arguments-constant": 148000, - "verifySignature-cpu-arguments-slope": 1, + "unBData-cpu-arguments": 31220, + "unBData-memory-arguments": 32, + "unConstrData-cpu-arguments": 32696, "unConstrData-memory-arguments": 32, - "quotientInteger-memory-arguments-intercept": 0, - "equalsByteString-cpu-arguments-constant": 150000, - "sliceByteString-memory-arguments-intercept": 0, - "mkPairData-memory-arguments": 32, - "equalsByteString-cpu-arguments-intercept": 112536, - "appendString-memory-arguments-slope": 1, - "lessThanInteger-cpu-arguments-slope": 497, - "modInteger-cpu-arguments-model-arguments-intercept": 425507, - "modInteger-memory-arguments-minimum": 1, - "sha3_256-cpu-arguments-intercept": 0, - "verifySignature-memory-arguments": 1, - "cekLamCost-exBudgetMemory": 100, - "sliceByteString-cpu-arguments-intercept": 150000 + "unIData-cpu-arguments": 43357, + "unIData-memory-arguments": 32, + "unListData-cpu-arguments": 32247, + "unListData-memory-arguments": 32, + "unMapData-cpu-arguments": 38314, + "unMapData-memory-arguments": 32, + "verifyEd25519Signature-cpu-arguments-intercept": 57996947, + "verifyEd25519Signature-cpu-arguments-slope": 18975, + "verifyEd25519Signature-memory-arguments": 10 }, "PlutusV2": { "addInteger-cpu-arguments-intercept": 205665, @@ -361,12 +361,12 @@ "unMapData-memory-arguments": 32, "verifyEcdsaSecp256k1Signature-cpu-arguments": 35892428, "verifyEcdsaSecp256k1Signature-memory-arguments": 10, - "verifyEd25519Signature-cpu-arguments-intercept": 9462713, - "verifyEd25519Signature-cpu-arguments-slope": 1021, + "verifyEd25519Signature-cpu-arguments-intercept": 57996947, + "verifyEd25519Signature-cpu-arguments-slope": 18975, "verifyEd25519Signature-memory-arguments": 10, "verifySchnorrSecp256k1Signature-cpu-arguments-intercept": 38887044, "verifySchnorrSecp256k1Signature-cpu-arguments-slope": 32947, "verifySchnorrSecp256k1Signature-memory-arguments": 10 } } -} \ No newline at end of file +} From 8b0731de9d7c1f0b7eb22528b1e3d22071931b40 Mon Sep 17 00:00:00 2001 From: Angel Castillo Date: Thu, 23 May 2024 17:32:51 +0800 Subject: [PATCH 2/3] feat(cardano-services): chain history provider now also returns reference scripts on outputs --- .../DbSyncChainHistory/ChainHistoryBuilder.ts | 34 ++++++++++++++++++- .../DbSyncChainHistory/mappers.ts | 26 ++++++++++++-- .../DbSyncChainHistory/queries.ts | 9 +++++ .../ChainHistory/DbSyncChainHistory/types.ts | 9 +++++ .../ChainHistoryHttpService.test.ts | 9 +++++ .../mappers.test.ts | 30 ++++++++++++++-- .../ChainHistory/fixtures/FixtureBuilder.ts | 8 +++-- .../test/ChainHistory/fixtures/queries.ts | 8 +++-- 8 files changed, 121 insertions(+), 12 deletions(-) diff --git a/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/ChainHistoryBuilder.ts b/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/ChainHistoryBuilder.ts index ea1f690c122..1fc6e6ff186 100644 --- a/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/ChainHistoryBuilder.ts +++ b/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/ChainHistoryBuilder.ts @@ -8,12 +8,14 @@ import { PoolRegisterCertModel, PoolRetireCertModel, RedeemerModel, + ScriptModel, StakeCertModel, TransactionDataMap, TxIdModel, TxInput, TxInputModel, TxOutMultiAssetModel, + TxOutScriptMap, TxOutTokenMap, TxOutput, TxOutputModel, @@ -28,6 +30,7 @@ import { Pool, QueryResult } from 'pg'; import { Range, hexStringToBuffer } from '@cardano-sdk/util'; import { mapCertificate, + mapPlutusScript, mapRedeemer, mapTxId, mapTxInModel, @@ -68,6 +71,28 @@ export class ChainHistoryBuilder { return mapTxOutTokenMap(result.rows); } + public async queryReferenceScriptsByTxOut(txOutModel: TxOutputModel[]): Promise { + const txScriptMap: TxOutScriptMap = new Map(); + + for (const model of txOutModel) { + if (model.reference_script_id) { + const result: QueryResult = await this.#db.query({ + name: 'tx_reference_scripts_by_tx_out_ids', + text: Queries.findReferenceScriptsById, + values: [[model.reference_script_id]] + }); + + if (result.rows.length === 0) continue; + if (result.rows[0].type === 'timelock') continue; // Shouldn't happen. + + // There can only be one refScript per output. + txScriptMap.set(model.id, mapPlutusScript(result.rows[0])); + } + } + + return txScriptMap; + } + public async queryTransactionOutputsByIds(ids: string[], collateral = false): Promise { this.#logger.debug(`About to find outputs (collateral: ${collateral}) for transactions with ids:`, ids); const result: QueryResult = await this.#db.query({ @@ -79,7 +104,14 @@ export class ChainHistoryBuilder { const txOutIds = result.rows.flatMap((txOut) => BigInt(txOut.id)); const multiAssets = await this.queryMultiAssetsByTxOut(txOutIds); - return result.rows.map((txOut) => mapTxOutModel(txOut, multiAssets.get(txOut.id))); + const referenceScripts = await this.queryReferenceScriptsByTxOut(result.rows); + + return result.rows.map((txOut) => + mapTxOutModel(txOut, { + assets: multiAssets.get(txOut.id), + script: referenceScripts.get(txOut.id) + }) + ); } public async queryTxMintByIds(ids: string[]): Promise { diff --git a/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/mappers.ts b/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/mappers.ts index 119188c8547..7d5b9a20812 100644 --- a/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/mappers.ts +++ b/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/mappers.ts @@ -1,11 +1,12 @@ import * as Crypto from '@cardano-sdk/crypto'; -import { BigIntMath } from '@cardano-sdk/util'; +import { BigIntMath, HexBlob } from '@cardano-sdk/util'; import { BlockModel, BlockOutputModel, CertificateModel, MultiAssetModel, RedeemerModel, + ScriptModel, TipModel, TxIdModel, TxInput, @@ -84,14 +85,18 @@ export const mapTxOut = (txOut: TxOutput): Cardano.TxOut => ({ value: txOut.value }); -export const mapTxOutModel = (txOutModel: TxOutputModel, assets?: Cardano.TokenMap): TxOutput => ({ +export const mapTxOutModel = ( + txOutModel: TxOutputModel, + props: { assets?: Cardano.TokenMap; script?: Cardano.Script } +): TxOutput => ({ address: txOutModel.address as unknown as Cardano.PaymentAddress, // Inline datums are missing, but for now it's ok on ChainHistoryProvider datumHash: txOutModel.datum ? (txOutModel.datum.toString('hex') as unknown as Hash32ByteBase16) : undefined, index: txOutModel.index, + scriptReference: props.script, txId: txOutModel.tx_id.toString('hex') as unknown as Cardano.TransactionId, value: { - assets: assets && assets.size > 0 ? assets : undefined, + assets: props.assets && props.assets.size > 0 ? props.assets : undefined, coins: BigInt(txOutModel.coin_value) } }); @@ -101,6 +106,21 @@ export const mapWithdrawal = (withdrawalModel: WithdrawalModel): Cardano.Withdra stakeAddress: withdrawalModel.stake_address as unknown as Cardano.RewardAccount }); +export const mapPlutusScript = (scriptModel: ScriptModel): Cardano.Script => { + const cbor = scriptModel.bytes.toString('hex') as HexBlob; + + return { + __type: Cardano.ScriptType.Plutus, + bytes: cbor, + version: + scriptModel.type === 'plutusV1' + ? Cardano.PlutusLanguageVersion.V1 + : scriptModel.type === 'plutusV2' + ? Cardano.PlutusLanguageVersion.V2 + : Cardano.PlutusLanguageVersion.V3 + }; +}; + // TODO: unfortunately this is not nullable and not implemented. // Remove this and select the actual redeemer data from `redeemer_data` table. const stubRedeemerData = Buffer.from('not implemented'); diff --git a/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/queries.ts b/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/queries.ts index ed03b11ec29..a645996814d 100644 --- a/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/queries.ts +++ b/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/queries.ts @@ -22,6 +22,7 @@ const selectTxOutput = (collateral = false) => ` tx_out."index" AS "index", tx_out.value AS coin_value, tx_out.data_hash AS datum, + tx_out.reference_script_id as reference_script_id, tx.hash AS tx_id FROM ${collateral ? 'collateral_tx_out' : 'tx_out'} AS tx_out JOIN tx ON tx_out.tx_id = tx.id`; @@ -120,6 +121,14 @@ export const findMultiAssetByTxOut = ` WHERE tx_out.id = ANY($1) ORDER BY ma_out.id ASC`; +export const findReferenceScriptsById = ` + SELECT + script.type AS type, + script.bytes AS bytes, + script.serialised_size AS serialized_size + FROM script AS script + WHERE id = ANY($1)`; + export const findTxMintByIds = ` SELECT mint.quantity AS quantity, diff --git a/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/types.ts b/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/types.ts index af68567e4a5..9469827640d 100644 --- a/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/types.ts +++ b/packages/cardano-services/src/ChainHistory/DbSyncChainHistory/types.ts @@ -3,6 +3,7 @@ import { Cardano } from '@cardano-sdk/core'; export type TransactionDataMap = Map; export type TxOutTokenMap = Map; export type TxTokenMap = TransactionDataMap; +export type TxOutScriptMap = Map; export interface BlockModel { block_no: number; @@ -67,6 +68,7 @@ export interface TxOutputModel { datum?: Buffer | null; id: string; index: number; + reference_script_id: number | null; tx_id: Buffer; } @@ -87,6 +89,13 @@ export interface TxOutMultiAssetModel extends MultiAssetModel { tx_out_id: string; } +export interface ScriptModel { + type: 'timelock' | 'plutusV1' | 'plutusV2' | 'plutusV3'; + bytes: Buffer; + hash: Buffer; + serialised_size: number; +} + export interface WithdrawalModel { quantity: string; tx_id: Buffer; diff --git a/packages/cardano-services/test/ChainHistory/ChainHistoryHttpService.test.ts b/packages/cardano-services/test/ChainHistory/ChainHistoryHttpService.test.ts index 9652e0fa3a4..4c67fb80059 100644 --- a/packages/cardano-services/test/ChainHistory/ChainHistoryHttpService.test.ts +++ b/packages/cardano-services/test/ChainHistory/ChainHistoryHttpService.test.ts @@ -313,6 +313,15 @@ describe('ChainHistoryHttpService', () => { expect(tx.auxiliaryData).toBeDefined(); }); + it('has script reference data', async () => { + const response = await provider.transactionsByHashes({ + ids: await fixtureBuilder.getTxHashes(1, { with: [TxWith.ScriptReference] }) + }); + const tx: Cardano.HydratedTx = response[0]; + expect(response.length).toEqual(1); + expect(tx.body.outputs.some((txOut) => !!txOut.scriptReference)).toBeTruthy(); + }); + it('has collateral inputs', async () => { const response = await provider.transactionsByHashes({ ids: await fixtureBuilder.getTxHashes(1, { with: [TxWith.CollateralInput] }) diff --git a/packages/cardano-services/test/ChainHistory/DbSyncChainHistoryProvider/mappers.test.ts b/packages/cardano-services/test/ChainHistory/DbSyncChainHistoryProvider/mappers.test.ts index 1fc00c65924..9b93acd0986 100644 --- a/packages/cardano-services/test/ChainHistory/DbSyncChainHistoryProvider/mappers.test.ts +++ b/packages/cardano-services/test/ChainHistory/DbSyncChainHistoryProvider/mappers.test.ts @@ -26,6 +26,7 @@ import { } from '../../../src/ChainHistory/DbSyncChainHistory/types'; import { Cardano } from '@cardano-sdk/core'; import { Hash28ByteBase16, Hash32ByteBase16 } from '@cardano-sdk/crypto'; +import { HexBlob } from '@cardano-sdk/util'; const blockHash = '7a48b034645f51743550bbaf81f8a14771e58856e031eb63844738ca8ad72298'; const poolId = 'pool1zuevzm3xlrhmwjw87ec38mzs02tlkwec9wxpgafcaykmwg7efhh'; @@ -77,12 +78,22 @@ const txOutModel: TxOutputModel = { datum: Buffer.from(hash32ByteBase16, 'hex'), id: '1', index: 1, + reference_script_id: 0, tx_id: Buffer.from(transactionHash, 'hex') }; const assets: Cardano.TokenMap = new Map([ [AssetId.TSLA, 500n], [AssetId.PXL, 500n] ]); + +const script: Cardano.PlutusScript = { + __type: Cardano.ScriptType.Plutus, + bytes: HexBlob( + '59079201000033232323232323232323232323232332232323232323232222232325335333006300800530070043333573466E1CD55CEA80124000466442466002006004646464646464646464646464646666AE68CDC39AAB9D500C480008CCCCCCCCCCCC88888888888848CCCCCCCCCCCC00403403002C02802402001C01801401000C008CD4060064D5D0A80619A80C00C9ABA1500B33501801A35742A014666AA038EB9406CD5D0A804999AA80E3AE501B35742A01066A0300466AE85401CCCD54070091D69ABA150063232323333573466E1CD55CEA801240004664424660020060046464646666AE68CDC39AAB9D5002480008CC8848CC00400C008CD40B9D69ABA15002302F357426AE8940088C98C80C8CD5CE01981901809AAB9E5001137540026AE854008C8C8C8CCCD5CD19B8735573AA004900011991091980080180119A8173AD35742A004605E6AE84D5D1280111931901919AB9C033032030135573CA00226EA8004D5D09ABA2500223263202E33573805E05C05826AAE7940044DD50009ABA1500533501875C6AE854010CCD540700808004D5D0A801999AA80E3AE200135742A00460446AE84D5D1280111931901519AB9C02B02A028135744A00226AE8940044D5D1280089ABA25001135744A00226AE8940044D5D1280089ABA25001135744A00226AE8940044D55CF280089BAA00135742A00460246AE84D5D1280111931900E19AB9C01D01C01A101B13263201B3357389201035054350001B135573CA00226EA80054049404448C88C008DD6000990009AA80A911999AAB9F0012500A233500930043574200460066AE880080548C8C8CCCD5CD19B8735573AA004900011991091980080180118061ABA150023005357426AE8940088C98C8054CD5CE00B00A80989AAB9E5001137540024646464646666AE68CDC39AAB9D5004480008CCCC888848CCCC00401401000C008C8C8C8CCCD5CD19B8735573AA0049000119910919800801801180A9ABA1500233500F014357426AE8940088C98C8068CD5CE00D80D00C09AAB9E5001137540026AE854010CCD54021D728039ABA150033232323333573466E1D4005200423212223002004357426AAE79400C8CCCD5CD19B875002480088C84888C004010DD71ABA135573CA00846666AE68CDC3A801A400042444006464C6403866AE700740700680640604D55CEA80089BAA00135742A00466A016EB8D5D09ABA2500223263201633573802E02C02826AE8940044D5D1280089AAB9E500113754002266AA002EB9D6889119118011BAB00132001355012223233335573E0044A010466A00E66442466002006004600C6AAE754008C014D55CF280118021ABA200301313574200222440042442446600200800624464646666AE68CDC3A800A40004642446004006600A6AE84D55CF280191999AB9A3370EA0049001109100091931900899AB9C01201100F00E135573AA00226EA80048C8C8CCCD5CD19B875001480188C848888C010014C01CD5D09AAB9E500323333573466E1D400920042321222230020053009357426AAE7940108CCCD5CD19B875003480088C848888C004014C01CD5D09AAB9E500523333573466E1D40112000232122223003005375C6AE84D55CF280311931900899AB9C01201100F00E00D00C135573AA00226EA80048C8C8CCCD5CD19B8735573AA004900011991091980080180118029ABA15002375A6AE84D5D1280111931900699AB9C00E00D00B135573CA00226EA80048C8CCCD5CD19B8735573AA002900011BAE357426AAE7940088C98C802CCD5CE00600580489BAA001232323232323333573466E1D4005200C21222222200323333573466E1D4009200A21222222200423333573466E1D400D2008233221222222233001009008375C6AE854014DD69ABA135744A00A46666AE68CDC3A8022400C4664424444444660040120106EB8D5D0A8039BAE357426AE89401C8CCCD5CD19B875005480108CC8848888888CC018024020C030D5D0A8049BAE357426AE8940248CCCD5CD19B875006480088C848888888C01C020C034D5D09AAB9E500B23333573466E1D401D2000232122222223005008300E357426AAE7940308C98C8050CD5CE00A80A00900880800780700680609AAB9D5004135573CA00626AAE7940084D55CF280089BAA0012323232323333573466E1D400520022333222122333001005004003375A6AE854010DD69ABA15003375A6AE84D5D1280191999AB9A3370EA0049000119091180100198041ABA135573CA00C464C6401A66AE7003803402C0284D55CEA80189ABA25001135573CA00226EA80048C8C8CCCD5CD19B875001480088C8488C00400CDD71ABA135573CA00646666AE68CDC3A8012400046424460040066EB8D5D09AAB9E500423263200A33573801601401000E26AAE7540044DD500089119191999AB9A3370EA00290021091100091999AB9A3370EA00490011190911180180218031ABA135573CA00846666AE68CDC3A801A400042444004464C6401666AE7003002C02402001C4D55CEA80089BAA0012323333573466E1D40052002212200223333573466E1D40092000212200123263200733573801000E00A00826AAE74DD5000891999AB9A3370E6AAE74DD5000A40004008464C6400866AE700140100092612001490103505431001123230010012233003300200200122212200201' + ), + version: Cardano.PlutusLanguageVersion.V2 +}; + const multiAssetModel: MultiAssetModel = { asset_name: Buffer.from(assetName, 'hex'), fingerprint, @@ -459,7 +470,7 @@ describe('chain history mappers', () => { }); describe('mapTxOutModel', () => { test('map TxOutputModel with assets to TxOutput', () => { - const result = mappers.mapTxOutModel(txOutModel, assets); + const result = mappers.mapTxOutModel(txOutModel, { assets }); expect(result).toEqual({ address: Cardano.PaymentAddress(address), datumHash: Hash32ByteBase16(hash32ByteBase16), @@ -468,8 +479,21 @@ describe('chain history mappers', () => { value: { assets, coins: 20_000_000n } }); }); + + test('map TxOutputModel with reference script to TxOutput', () => { + const result = mappers.mapTxOutModel(txOutModel, { assets, script }); + expect(result).toEqual({ + address: Cardano.PaymentAddress(address), + datumHash: Hash32ByteBase16(hash32ByteBase16), + index: 1, + scriptReference: script, + txId: Cardano.TransactionId(transactionHash), + value: { assets, coins: 20_000_000n } + }); + }); + test('map TxOutputModel with no assets to TxOutput', () => { - const result = mappers.mapTxOutModel(txOutModel); + const result = mappers.mapTxOutModel(txOutModel, {}); expect(result).toEqual({ address: Cardano.PaymentAddress(address), datumHash: Hash32ByteBase16(hash32ByteBase16), @@ -479,7 +503,7 @@ describe('chain history mappers', () => { }); }); test('map TxOutputModel with nulls to TxOutput', () => { - const result = mappers.mapTxOutModel({ ...txOutModel, datum: null }); + const result = mappers.mapTxOutModel({ ...txOutModel, datum: null }, {}); expect(result).toEqual({ address: Cardano.PaymentAddress(address), index: 1, diff --git a/packages/cardano-services/test/ChainHistory/fixtures/FixtureBuilder.ts b/packages/cardano-services/test/ChainHistory/fixtures/FixtureBuilder.ts index 5319d0cd6d8..42ea353ec3f 100644 --- a/packages/cardano-services/test/ChainHistory/fixtures/FixtureBuilder.ts +++ b/packages/cardano-services/test/ChainHistory/fixtures/FixtureBuilder.ts @@ -18,7 +18,8 @@ export enum TxWith { MultiAsset = 'multiAsset', Redeemer = 'redeemer', Withdrawal = 'withdrawal', - CollateralOutput = 'collateralOutput' + CollateralOutput = 'collateralOutput', + ScriptReference = 'scriptReference' } export type AddressesInBlockRange = { @@ -122,11 +123,12 @@ export class ChainHistoryFixtureBuilder { if (options.with.includes(TxWith.MirCertificate)) query += Queries.latestTxHashesWithMirCerts; if (options.with.includes(TxWith.Withdrawal)) query += Queries.latestTxHashesWithWithdrawal; if (options.with.includes(TxWith.CollateralOutput)) query += Queries.latestTxHashesWithCollateralOutput; + if (options.with.includes(TxWith.ScriptReference)) query += Queries.latestTxHashesWithScriptReference; query += Queries.endLatestTxHashes; } - const result: QueryResult<{ hash: Buffer }> = await this.#db.query(query, [desiredQty]); + const result: QueryResult<{ tx_hash: Buffer }> = await this.#db.query(query, [desiredQty]); const resultsQty = result.rows.length; if (result.rows.length === 0) { @@ -134,7 +136,7 @@ export class ChainHistoryFixtureBuilder { } else if (resultsQty < desiredQty) { this.#logger.warn(`${desiredQty} transactions desired, only ${resultsQty} results found`); } - return result.rows.map(({ hash }) => bufferToHexString(hash) as unknown as Cardano.TransactionId); + return result.rows.map(({ tx_hash }) => bufferToHexString(tx_hash) as unknown as Cardano.TransactionId); } public async getMultiAssetTxOutIds(desiredQty: number) { diff --git a/packages/cardano-services/test/ChainHistory/fixtures/queries.ts b/packages/cardano-services/test/ChainHistory/fixtures/queries.ts index 5d6b95d13ba..147844326ee 100644 --- a/packages/cardano-services/test/ChainHistory/fixtures/queries.ts +++ b/packages/cardano-services/test/ChainHistory/fixtures/queries.ts @@ -12,13 +12,13 @@ export const latestBlockHashes = ` LIMIT $1`; export const latestTxHashes = ` - SELECT hash + SELECT tx.hash as tx_hash FROM tx ORDER BY id DESC LIMIT $1`; export const beginLatestTxHashes = ` - SELECT hash FROM tx + SELECT tx.hash as tx_hash FROM tx JOIN tx_out ON tx_out.tx_id = tx.id`; export const latestTxHashesWithMultiAsset = ` @@ -60,6 +60,10 @@ export const latestTxHashesWithWithdrawal = ` export const latestTxHashesWithCollateralOutput = ` JOIN collateral_tx_out ON collateral_tx_out.tx_id = tx.id`; +export const latestTxHashesWithScriptReference = ` + JOIN script ON script.tx_id = tx.id + WHERE tx_out.reference_script_id IS NOT NULL`; + export const endLatestTxHashes = ` GROUP BY tx.id ORDER BY tx.id DESC From 936351e22bea0b673e683333c84cbf9d0e134e19 Mon Sep 17 00:00:00 2001 From: Angel Castillo Date: Tue, 21 May 2024 14:08:41 +0800 Subject: [PATCH 3/3] feat: tx-builder now supports spending from plutus scripts BREAKING CHANGE: Input selectors now return selected inputs in lexicographic order - new input selection parameter added 'mustSpendUtxo', which force such UTXOs to be part of the selection - txBuilder now takes a new optional dependency TxEvaluator - added to the txBuilder the following new methods 'addInput', 'addReferenceInput' and 'addDatum' - the txBuilder now supports spending from script inputs - the txBuilder now resolve unknown inputs from on-chain data - outputBuilder 'datum' function can now take PlutusData as inline datum - added to the OutputBuilder a new method 'scriptReference' - walletUtilContext now requires an additional property 'chainHistoryProvider' - initializeTx now takes the list of redeemerByType and the script versions of the plutus scripts in the transaction --- .../core/src/Serialization/Common/Datum.ts | 2 +- .../Serialization/PlutusData/PlutusData.ts | 13 + .../Redeemer/Redeemer.ts | 13 + .../test/Serialization/PlutusData.test.ts | 7 + .../TransactionWitnessSet/Redeemer.test.ts | 7 + .../multisig-wallet/MultiSigWallet.ts | 7 +- .../PersonalWallet/phase2validation.test.ts | 3 +- .../PersonalWallet/plutusTest.test.ts | 346 +++++++++++ .../GreedySelection/GreedyInputSelector.ts | 30 +- .../src/GreedySelection/util.ts | 21 + .../src/RoundRobinRandomImprove/change.ts | 20 +- .../src/RoundRobinRandomImprove/index.ts | 13 +- .../src/RoundRobinRandomImprove/roundRobin.ts | 3 +- packages/input-selection/src/types.ts | 11 +- packages/input-selection/src/util.ts | 8 +- .../GreedySelection/GreedySelection.test.ts | 97 +++ .../InputSelectionPropertyTesting.test.ts | 53 +- .../test/RoundRobinRandomImprove.test.ts | 40 ++ .../test/util/selectionConstraints.ts | 2 +- packages/input-selection/test/util/tests.ts | 6 + .../src/createTransactionInternals.ts | 27 +- .../input-selection/selectionConstraints.ts | 114 +++- .../src/tx-builder/GreedyTxEvaluator.ts | 44 ++ .../src/tx-builder/OutputBuilder.ts | 20 +- .../src/tx-builder/TxBuilder.ts | 319 +++++++--- .../src/tx-builder/costModels.ts | 88 +++ .../tx-construction/src/tx-builder/index.ts | 1 + .../src/tx-builder/initializeTx.ts | 27 +- .../tx-construction/src/tx-builder/types.ts | 91 ++- .../tx-construction/src/tx-builder/utils.ts | 215 +++++++ packages/tx-construction/src/types.ts | 15 +- .../test/computeScriptDataHash.test.ts | 47 +- .../test/createTransactionInternals.test.ts | 1 + .../selectionConstraints.test.ts | 72 ++- .../test/tx-builder/GreedyTxEvaluator.test.ts | 48 ++ .../tx-builder/TxBuilder.bootstrap.test.ts | 2 + .../test/tx-builder/TxBuilder.test.ts | 24 +- .../TxBuilderDelegatePortfolio.test.ts | 3 + .../tx-builder/TxBuilderPlutusScripts.test.ts | 562 ++++++++++++++++++ .../tx-construction/test/tx-builder/mocks.ts | 11 + .../util-dev/src/mockProviders/mockData.ts | 4 + packages/wallet/src/Wallets/BaseWallet.ts | 3 + packages/wallet/src/services/WalletUtil.ts | 5 +- .../test/PersonalWallet/methods.test.ts | 1 - .../src/observableWallet/util.ts | 12 + 45 files changed, 2270 insertions(+), 188 deletions(-) create mode 100644 packages/e2e/test/wallet_epoch_0/PersonalWallet/plutusTest.test.ts create mode 100644 packages/tx-construction/src/tx-builder/GreedyTxEvaluator.ts create mode 100644 packages/tx-construction/src/tx-builder/costModels.ts create mode 100644 packages/tx-construction/src/tx-builder/utils.ts create mode 100644 packages/tx-construction/test/tx-builder/GreedyTxEvaluator.test.ts create mode 100644 packages/tx-construction/test/tx-builder/TxBuilderPlutusScripts.test.ts diff --git a/packages/core/src/Serialization/Common/Datum.ts b/packages/core/src/Serialization/Common/Datum.ts index a2445e59cd6..4793ffd7bd1 100644 --- a/packages/core/src/Serialization/Common/Datum.ts +++ b/packages/core/src/Serialization/Common/Datum.ts @@ -11,7 +11,7 @@ const DATUM_ARRAY_SIZE = 2; * * @param datum The datum to be checked for. */ -const isDatumHash = (datum: unknown): datum is Cardano.DatumHash => datum !== null && typeof datum === 'string'; +export const isDatumHash = (datum: unknown): datum is Cardano.DatumHash => datum !== null && typeof datum === 'string'; /** Represents different ways of associating a Datum with a UTxO in a transaction. */ export enum DatumKind { diff --git a/packages/core/src/Serialization/PlutusData/PlutusData.ts b/packages/core/src/Serialization/PlutusData/PlutusData.ts index 84d38034b8d..4de2f01a854 100644 --- a/packages/core/src/Serialization/PlutusData/PlutusData.ts +++ b/packages/core/src/Serialization/PlutusData/PlutusData.ts @@ -1,4 +1,5 @@ import * as Cardano from '../../Cardano'; +import * as Crypto from '@cardano-sdk/crypto'; import { CborReader, CborReaderState, CborTag, CborWriter } from '../CBOR'; import { ConstrPlutusData } from './ConstrPlutusData'; import { HexBlob } from '@cardano-sdk/util'; @@ -11,6 +12,7 @@ import { bytesToHex } from '../../util/misc'; const MAX_WORD64 = 18_446_744_073_709_551_615n; const INDEFINITE_BYTE_STRING = new Uint8Array([95]); const MAX_BYTE_STRING_CHUNK_SIZE = 64; +const HASH_LENGTH_IN_BYTES = 32; /** * A type corresponding to the Plutus Core Data datatype. @@ -212,6 +214,17 @@ export class PlutusData { } } + /** + * Computes the plutus data hash. + * + * @returns the plutus data hash. + */ + hash(): Crypto.Hash32ByteBase16 { + const hash = Crypto.blake2b(HASH_LENGTH_IN_BYTES).update(Buffer.from(this.toCbor(), 'hex')).digest(); + + return Crypto.Hash32ByteBase16(HexBlob.fromBytes(hash)); + } + /** * Creates a PlutusData object from the given Core PlutusData object. * diff --git a/packages/core/src/Serialization/TransactionWitnessSet/Redeemer/Redeemer.ts b/packages/core/src/Serialization/TransactionWitnessSet/Redeemer/Redeemer.ts index defed96139b..e0a8dda763c 100644 --- a/packages/core/src/Serialization/TransactionWitnessSet/Redeemer/Redeemer.ts +++ b/packages/core/src/Serialization/TransactionWitnessSet/Redeemer/Redeemer.ts @@ -1,4 +1,5 @@ import * as Cardano from '../../../Cardano'; +import * as Crypto from '@cardano-sdk/crypto'; import { CborReader, CborWriter } from '../../CBOR'; import { ExUnits } from '../../Common'; import { HexBlob, InvalidArgumentError, InvalidStateError } from '@cardano-sdk/util'; @@ -7,6 +8,7 @@ import { RedeemerTag } from './RedeemerTag'; import { hexToBytes } from '../../../util/misc'; const REDEEMER_ARRAY_SIZE = 4; +const HASH_LENGTH_IN_BYTES = 32; /** * The Redeemer is an argument provided to a Plutus smart contract (script) when @@ -243,4 +245,15 @@ export class Redeemer { this.#exUnits = exUnits; this.#originalBytes = undefined; } + + /** + * Computes the redeemer hash. + * + * @returns the redeemer hash. + */ + hash(): Crypto.Hash32ByteBase16 { + const hash = Crypto.blake2b(HASH_LENGTH_IN_BYTES).update(Buffer.from(this.toCbor(), 'hex')).digest(); + + return Crypto.Hash32ByteBase16(HexBlob.fromBytes(hash)); + } } diff --git a/packages/core/test/Serialization/PlutusData.test.ts b/packages/core/test/Serialization/PlutusData.test.ts index 382e1272d10..8da32d50416 100644 --- a/packages/core/test/Serialization/PlutusData.test.ts +++ b/packages/core/test/Serialization/PlutusData.test.ts @@ -19,6 +19,13 @@ describe('PlutusData', () => { expect(() => Serialization.PlutusData.fromCbor(cbor)).not.toThrowError(); }); + it('can compute correct hash', () => { + const data = Serialization.PlutusData.fromCbor(HexBlob('46010203040506')); + + // Hash was generated with the CSL + expect(data.hash()).toEqual('f5e45fd57d6c5591dd9e83e76943827c4f4a9eacefd5ac974f48afd8420765a6'); + }); + describe('Integer', () => { it('can encode a positive integer', () => { const data = Serialization.PlutusData.newInteger(5n); diff --git a/packages/core/test/Serialization/TransactionWitnessSet/Redeemer.test.ts b/packages/core/test/Serialization/TransactionWitnessSet/Redeemer.test.ts index 6baffd5e2ac..029058be069 100644 --- a/packages/core/test/Serialization/TransactionWitnessSet/Redeemer.test.ts +++ b/packages/core/test/Serialization/TransactionWitnessSet/Redeemer.test.ts @@ -45,6 +45,13 @@ describe('Redeemer', () => { expect(redeemer.index()).toEqual(0n); }); + it('can compute correct hash', () => { + const redeemer = Redeemer.fromCore(core); + + // Hash was generated with the CSL + expect(redeemer.hash()).toEqual('cfa253874f5f17b01d44e33377124e12fa0e7c8bcd88067fb9edb8c5f5ec662e'); + }); + describe('Redeemer tag: Spend', () => { const spendCore = { ...core, purpose: RedeemerPurpose.spend }; const spendCbor = HexBlob('840000d8799f0102030405ff821b000086788ffc4e831b00015060e9e46451'); diff --git a/packages/e2e/test/long-running/multisig-wallet/MultiSigWallet.ts b/packages/e2e/test/long-running/multisig-wallet/MultiSigWallet.ts index 5f1e1c56de2..7b4b1809cea 100644 --- a/packages/e2e/test/long-running/multisig-wallet/MultiSigWallet.ts +++ b/packages/e2e/test/long-running/multisig-wallet/MultiSigWallet.ts @@ -18,11 +18,11 @@ import { nativeScriptPolicyId, util } from '@cardano-sdk/core'; +import { GreedyTxEvaluator, defaultSelectionConstraints } from '@cardano-sdk/tx-construction'; import { InputSelector, StaticChangeAddressResolver, roundRobinRandomImprove } from '@cardano-sdk/input-selection'; import { MultiSigTx } from './MultiSigTx'; import { Observable, firstValueFrom, interval, map, switchMap } from 'rxjs'; import { WalletNetworkInfoProvider } from '@cardano-sdk/wallet'; -import { defaultSelectionConstraints } from '@cardano-sdk/tx-construction'; const randomHexChar = () => Math.floor(Math.random() * 16).toString(16); const randomPublicKey = () => Crypto.Ed25519PublicKeyHex(Array.from({ length: 64 }).map(randomHexChar).join('')); @@ -464,7 +464,9 @@ export class MultiSigWallet { } }; }, - protocolParameters + protocolParameters, + redeemersByType: {}, + txEvaluator: new GreedyTxEvaluator(() => this.#networkInfoProvider.protocolParameters()) }); const implicitCoin = Cardano.util.computeImplicitCoin(protocolParameters, { @@ -476,6 +478,7 @@ export class MultiSigWallet { constraints, implicitValue: { coin: implicitCoin }, outputs: txOuts || new Set(), + preSelectedUtxo: new Set(), utxo: new Set(utxo) }); diff --git a/packages/e2e/test/wallet_epoch_0/PersonalWallet/phase2validation.test.ts b/packages/e2e/test/wallet_epoch_0/PersonalWallet/phase2validation.test.ts index 4379ea10124..1e837c4dccf 100644 --- a/packages/e2e/test/wallet_epoch_0/PersonalWallet/phase2validation.test.ts +++ b/packages/e2e/test/wallet_epoch_0/PersonalWallet/phase2validation.test.ts @@ -18,7 +18,7 @@ const localNetworkPlutusV2CostModel = [ 64_832, 32, 65_493, 32, 22_558, 32, 16_563, 32, 76_511, 32, 196_500, 453_240, 220, 0, 1, 1, 69_522, 11_687, 0, 1, 60_091, 32, 196_500, 453_240, 220, 0, 1, 1, 196_500, 453_240, 220, 0, 1, 1, 1_159_724, 392_670, 0, 2, 806_990, 30_482, 4, 1_927_926, 82_523, 4, 265_318, 0, 4, 0, 85_931, 32, 205_665, 812, 1, 1, 41_182, 32, 212_342, 32, 31_220, 32, - 32_696, 32, 43_357, 32, 32_247, 32, 38_314, 32, 35_892_428, 10, 9_462_713, 1021, 10, 38_887_044, 32_947, 10 + 32_696, 32, 43_357, 32, 32_247, 32, 38_314, 32, 35_892_428, 10, 57_996_947, 18_975, 10, 38_887_044, 32_947, 10 ]; /** @@ -134,6 +134,7 @@ describe('PersonalWallet/phase2validation', () => { } } ]), + redeemersByType: { [Cardano.RedeemerPurpose.mint]: [scriptRedeemer] }, scriptIntegrityHash: scriptDataHash, witness: { redeemers: [scriptRedeemer], scripts: [alwaysFailScript] } }; diff --git a/packages/e2e/test/wallet_epoch_0/PersonalWallet/plutusTest.test.ts b/packages/e2e/test/wallet_epoch_0/PersonalWallet/plutusTest.test.ts new file mode 100644 index 00000000000..aec7eee3913 --- /dev/null +++ b/packages/e2e/test/wallet_epoch_0/PersonalWallet/plutusTest.test.ts @@ -0,0 +1,346 @@ +import { BaseWallet } from '@cardano-sdk/wallet'; +import { Cardano, Serialization, UtxoProvider } from '@cardano-sdk/core'; +import { HexBlob, isNotNil } from '@cardano-sdk/util'; +import { Observable, filter, firstValueFrom, interval, map, switchMap, take } from 'rxjs'; +import { createLogger } from '@cardano-sdk/util-dev'; +import { getEnv, getWallet, utxoProviderFactory, walletReady, walletVariables } from '../../../src'; + +const env = getEnv(walletVariables); +const logger = createLogger(); + +const scriptRedeemer: Cardano.PlutusData = { + constructor: 0n, + fields: { + items: [21n] + } +}; + +const scriptDatum: Cardano.PlutusData = { + constructor: 0n, + fields: { + items: [42n] + } +}; + +const midnightClaimRedeemer: Cardano.PlutusData = { + items: [ + // chainCode + 2n, + // grantWallet + new Uint8Array([ + 203, 209, 183, 104, 17, 74, 8, 166, 94, 101, 33, 139, 195, 134, 35, 246, 25, 118, 87, 49, 207, 123, 41, 139, 32, + 144, 158, 153, 124, 40, 27, 102 + ]), + // rdmrGrantAmount + 1n, + // grantIx + 115n, + // rdmrCardanoPubKey + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + // rdmrMidnightKey + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]), + // rdmrPubKey + new Uint8Array([ + 214, 13, 16, 28, 145, 79, 106, 83, 253, 255, 40, 69, 88, 98, 130, 102, 222, 180, 1, 144, 68, 152, 115, 119, 107, + 51, 71, 105, 40, 115, 59, 69 + ]), + // rdmrSignature + new Uint8Array([ + 219, 89, 171, 195, 164, 168, 115, 84, 103, 158, 18, 134, 24, 127, 183, 137, 19, 50, 155, 210, 247, 198, 162, 186, + 239, 175, 25, 248, 84, 112, 137, 228, 121, 3, 218, 45, 137, 84, 103, 130, 61, 143, 172, 133, 145, 121, 132, 10, + 160, 14, 101, 204, 212, 12, 229, 134, 146, 67, 101, 114, 197, 31, 167, 5 + ]), + // hashes + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + ] +}; + +const midnightDatum = { + items: [ + // datumClaimMask + 340_199_290_171_201_906_221_318_119_490_500_689_920n, + // datumRootHash + new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + ] +}; + +const fundScript = async (wallet: BaseWallet, receivingAddress: Cardano.PaymentAddress, datum: Cardano.PlutusData) => { + const tAdaToSend = 10_000_000n; + // Make sure the wallet has sufficient funds to run this test + await walletReady(wallet, tAdaToSend); + + await firstValueFrom(wallet.syncStatus.isSettled$.pipe(filter((isSettled) => isSettled))); + + const [{ address: sendingAddress }] = await firstValueFrom(wallet.addresses$); + + logger.info(`Address ${sendingAddress} will send ${tAdaToSend} lovelace to address ${receivingAddress}.`); + + // Send 10 tADA to the same wallet. + const txBuilder = wallet.createTxBuilder(); + const txOutput = await txBuilder.buildOutput().address(receivingAddress).coin(tAdaToSend).datum(datum).build(); + const signedTx = (await txBuilder.addOutput(txOutput).build().sign()).tx; + await wallet.submitTx(signedTx); + + logger.info( + `Submitted transaction id: ${signedTx.id}, inputs: ${JSON.stringify( + signedTx.body.inputs.map((txIn) => [txIn.txId, txIn.index]) + )} and outputs:${JSON.stringify( + signedTx.body.outputs.map((txOut) => [txOut.address, Number.parseInt(txOut.value.coins.toString())]) + )}.` + ); + + const txFoundInHistory = await firstValueFrom( + wallet.transactions.history$.pipe( + map((txs) => txs.find((tx) => tx.id === signedTx.id)), + filter(isNotNil), + take(1) + ) + ); + + logger.info(`Found transaction id in chain history: ${txFoundInHistory.id}`); + + // Assert + expect(txFoundInHistory).toBeDefined(); + expect(txFoundInHistory.id).toEqual(signedTx.id); +}; + +const createScriptRefInput = async (wallet: BaseWallet, script: Cardano.Script): Promise => { + const tAdaToSend = 25_000_000n; + // Use dummy address to position the reference script + const refScriptAddress = Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ); + + // Make sure the wallet has sufficient funds to run this test + await walletReady(wallet, tAdaToSend); + + await firstValueFrom(wallet.syncStatus.isSettled$.pipe(filter((isSettled) => isSettled))); + + const [{ address: sendingAddress }] = await firstValueFrom(wallet.addresses$); + + logger.info(`Address ${sendingAddress} will send ${tAdaToSend} lovelace to address ${refScriptAddress}.`); + + const txBuilder = wallet.createTxBuilder(); + const txOutput = await txBuilder + .buildOutput() + .address(refScriptAddress) + .coin(tAdaToSend) + .scriptReference(script) + .build(); + + const signedTx = (await txBuilder.addOutput(txOutput).build().sign()).tx; + await wallet.submitTx(signedTx); + + logger.info( + `Submitted transaction id: ${signedTx.id}, inputs: ${JSON.stringify( + signedTx.body.inputs.map((txIn) => [txIn.txId, txIn.index]) + )} and outputs:${JSON.stringify( + signedTx.body.outputs.map((txOut) => [txOut.address, Number.parseInt(txOut.value.coins.toString())]) + )}.` + ); + + const txFoundInHistory = await firstValueFrom( + wallet.transactions.history$.pipe( + map((txs) => txs.find((tx) => tx.id === signedTx.id)), + filter(isNotNil), + take(1) + ) + ); + + logger.info(`Found transaction id in chain history: ${txFoundInHistory.id}`); + + // Assert + expect(txFoundInHistory).toBeDefined(); + expect(txFoundInHistory.id).toEqual(signedTx.id); + + const index = signedTx.body.outputs.findIndex((out) => !!out.scriptReference); + + return { index, txId: signedTx.id }; +}; + +const getScriptUtxoSet = ( + poolingInterval: number, + utxoProvider: UtxoProvider, + scriptAddress: Cardano.PaymentAddress +): Observable => + interval(poolingInterval).pipe( + switchMap( + () => + new Observable((subscriber) => { + utxoProvider + .utxoByAddresses({ addresses: [scriptAddress] }) + // eslint-disable-next-line promise/always-return + .then((utxos) => { + subscriber.next(utxos); + }) + .catch((error) => subscriber.error(error)); + }) + ) + ); + +describe('PersonalWallet/plutus', () => { + let wallet: BaseWallet; + afterAll(() => { + wallet.shutdown(); + }); + + it('can spend balance from a plutus script', async () => { + wallet = (await getWallet({ env, logger, name: 'Spending Wallet', polling: { interval: 50 } })).wallet; + // UTXO provider can be use to fetch the UTXO set from the script address. + const utxoProvider = await utxoProviderFactory.create(env.UTXO_PROVIDER, env.UTXO_PROVIDER_PARAMS, logger); + + // Plutus script that always returns true. + const alwaysSucceedsScript: Cardano.PlutusScript = { + __type: Cardano.ScriptType.Plutus, + bytes: HexBlob( + '59079201000033232323232323232323232323232332232323232323232222232325335333006300800530070043333573466E1CD55CEA80124000466442466002006004646464646464646464646464646666AE68CDC39AAB9D500C480008CCCCCCCCCCCC88888888888848CCCCCCCCCCCC00403403002C02802402001C01801401000C008CD4060064D5D0A80619A80C00C9ABA1500B33501801A35742A014666AA038EB9406CD5D0A804999AA80E3AE501B35742A01066A0300466AE85401CCCD54070091D69ABA150063232323333573466E1CD55CEA801240004664424660020060046464646666AE68CDC39AAB9D5002480008CC8848CC00400C008CD40B9D69ABA15002302F357426AE8940088C98C80C8CD5CE01981901809AAB9E5001137540026AE854008C8C8C8CCCD5CD19B8735573AA004900011991091980080180119A8173AD35742A004605E6AE84D5D1280111931901919AB9C033032030135573CA00226EA8004D5D09ABA2500223263202E33573805E05C05826AAE7940044DD50009ABA1500533501875C6AE854010CCD540700808004D5D0A801999AA80E3AE200135742A00460446AE84D5D1280111931901519AB9C02B02A028135744A00226AE8940044D5D1280089ABA25001135744A00226AE8940044D5D1280089ABA25001135744A00226AE8940044D55CF280089BAA00135742A00460246AE84D5D1280111931900E19AB9C01D01C01A101B13263201B3357389201035054350001B135573CA00226EA80054049404448C88C008DD6000990009AA80A911999AAB9F0012500A233500930043574200460066AE880080548C8C8CCCD5CD19B8735573AA004900011991091980080180118061ABA150023005357426AE8940088C98C8054CD5CE00B00A80989AAB9E5001137540024646464646666AE68CDC39AAB9D5004480008CCCC888848CCCC00401401000C008C8C8C8CCCD5CD19B8735573AA0049000119910919800801801180A9ABA1500233500F014357426AE8940088C98C8068CD5CE00D80D00C09AAB9E5001137540026AE854010CCD54021D728039ABA150033232323333573466E1D4005200423212223002004357426AAE79400C8CCCD5CD19B875002480088C84888C004010DD71ABA135573CA00846666AE68CDC3A801A400042444006464C6403866AE700740700680640604D55CEA80089BAA00135742A00466A016EB8D5D09ABA2500223263201633573802E02C02826AE8940044D5D1280089AAB9E500113754002266AA002EB9D6889119118011BAB00132001355012223233335573E0044A010466A00E66442466002006004600C6AAE754008C014D55CF280118021ABA200301313574200222440042442446600200800624464646666AE68CDC3A800A40004642446004006600A6AE84D55CF280191999AB9A3370EA0049001109100091931900899AB9C01201100F00E135573AA00226EA80048C8C8CCCD5CD19B875001480188C848888C010014C01CD5D09AAB9E500323333573466E1D400920042321222230020053009357426AAE7940108CCCD5CD19B875003480088C848888C004014C01CD5D09AAB9E500523333573466E1D40112000232122223003005375C6AE84D55CF280311931900899AB9C01201100F00E00D00C135573AA00226EA80048C8C8CCCD5CD19B8735573AA004900011991091980080180118029ABA15002375A6AE84D5D1280111931900699AB9C00E00D00B135573CA00226EA80048C8CCCD5CD19B8735573AA002900011BAE357426AAE7940088C98C802CCD5CE00600580489BAA001232323232323333573466E1D4005200C21222222200323333573466E1D4009200A21222222200423333573466E1D400D2008233221222222233001009008375C6AE854014DD69ABA135744A00A46666AE68CDC3A8022400C4664424444444660040120106EB8D5D0A8039BAE357426AE89401C8CCCD5CD19B875005480108CC8848888888CC018024020C030D5D0A8049BAE357426AE8940248CCCD5CD19B875006480088C848888888C01C020C034D5D09AAB9E500B23333573466E1D401D2000232122222223005008300E357426AAE7940308C98C8050CD5CE00A80A00900880800780700680609AAB9D5004135573CA00626AAE7940084D55CF280089BAA0012323232323333573466E1D400520022333222122333001005004003375A6AE854010DD69ABA15003375A6AE84D5D1280191999AB9A3370EA0049000119091180100198041ABA135573CA00C464C6401A66AE7003803402C0284D55CEA80189ABA25001135573CA00226EA80048C8C8CCCD5CD19B875001480088C8488C00400CDD71ABA135573CA00646666AE68CDC3A8012400046424460040066EB8D5D09AAB9E500423263200A33573801601401000E26AAE7540044DD500089119191999AB9A3370EA00290021091100091999AB9A3370EA00490011190911180180218031ABA135573CA00846666AE68CDC3A801A400042444004464C6401666AE7003002C02402001C4D55CEA80089BAA0012323333573466E1D40052002212200223333573466E1D40092000212200123263200733573801000E00A00826AAE74DD5000891999AB9A3370E6AAE74DD5000A40004008464C6400866AE700140100092612001490103505431001123230010012233003300200200122212200201' + ), + version: Cardano.PlutusLanguageVersion.V2 + }; + + const scriptHash = Serialization.Script.fromCore(alwaysSucceedsScript).hash(); + const scriptAddress = Cardano.EnterpriseAddress.fromCredentials(Cardano.NetworkId.Testnet, { + hash: scriptHash, + type: Cardano.CredentialType.ScriptHash + }) + .toAddress() + .toBech32() as Cardano.PaymentAddress; + + await fundScript(wallet, scriptAddress, scriptDatum); + await walletReady(wallet); + + const scriptUtxos = await firstValueFrom(getScriptUtxoSet(50, utxoProvider, scriptAddress)); + const bigScriptUtxo = scriptUtxos.find((utxo) => utxo[1].value.coins === 10_000_000n); + expect(bigScriptUtxo).toBeDefined(); + const [{ address: receivingAddress }] = await firstValueFrom(wallet.addresses$); + + const txBuilder = wallet.createTxBuilder(); + const txOutput = await txBuilder.buildOutput().address(receivingAddress).coin(10_000_000n).build(); + + const signedTx = ( + await txBuilder + .addInput(bigScriptUtxo!, { + datum: scriptDatum, + redeemer: scriptRedeemer, + script: alwaysSucceedsScript + }) + .addOutput(txOutput) + .setValidityInterval({ + invalidBefore: undefined, + invalidHereafter: undefined // HACK: setting any valid interval cause an error in the node: Uncomputable slot arithmetic; transaction's validity bounds go beyond the foreseeable end of the current era + }) + .build() + .sign() + ).tx; + + await wallet.submitTx(signedTx); + + logger.info( + `Submitted transaction id: ${signedTx.id}, inputs: ${JSON.stringify( + signedTx.body.inputs.map((txIn) => [txIn.txId, txIn.index]) + )} and outputs:${JSON.stringify( + signedTx.body.outputs.map((txOut) => [txOut.address, Number.parseInt(txOut.value.coins.toString())]) + )}.` + ); + + const txFoundInHistory = await firstValueFrom( + wallet.transactions.history$.pipe( + map((txs) => txs.find((tx) => tx.id === signedTx.id)), + filter(isNotNil), + take(1) + ) + ); + + logger.info(`Found transaction id in chain history: ${txFoundInHistory.id}`); + + // Assert + expect(txFoundInHistory).toBeDefined(); + expect(txFoundInHistory.id).toEqual(signedTx.id); + }); + + it('can spend balance from a plutus script using a reference script', async () => { + wallet = (await getWallet({ env, logger, name: 'Spending Wallet', polling: { interval: 50 } })).wallet; + // UTXO provider can be use to fetch the UTXO set from the script address. + const utxoProvider = await utxoProviderFactory.create(env.UTXO_PROVIDER, env.UTXO_PROVIDER_PARAMS, logger); + + const midnightClaimScript: Cardano.PlutusScript = { + __type: Cardano.ScriptType.Plutus, + bytes: HexBlob( + '59141d0100003333323233223322332232323232323232332232323232323232323232323232323232323232323232323232323232323232323222222223232323232323232323230012222223501422350102232323232533500910471533501522221323001225335500b1304d490101680015335323253350021001105332325335002100110545335014105415335330312350012222330443500422002304e02c500c1304f4910163002213500222225335005153335304b0021305549101640021323253350021001105c33023490101660032325335305c330380020011533533034002001105d105e105d33302301a01900c33304f75ca09c00a6604492101650035533530270011305649010131002215335001130584910131002215335001133064375c0086eb8008884c17124101310022350102253353304b00400213304b003001105e21305649010164002213057491013900532335330312350012222355010222235004223500a2232335005233500425333573466e3c0080045400c418c818c8cd4010818c94ccd5cd19b8f0020011500310631533500321533500221335002233500223305000200122206622233500420662225333573466e1c01800c54ccd5cd19b8700500213304000400110681068106115335001210611061500c153353302b500133702605266e00c0a4cc0b044888d400488cc01400401002120024800841504c13d24010136002213500222225335005153335304b0021305549101370021323253350021001105c3302349010161003303233304f75ca09c00a646460b06607000400264660b660b6466e09200100100133302301a01900c3550132222003330224901013800533532323500222350032232325335002100110623304e0040023303800400233050500901653353027001130564910131002215335001130584910131002215335001133053375a0086eb8008884c171241013100105b101e213056490101370022130574910139001530172222223500d2233553355335007105b1333011300c223500222372466e28cdc519b8a0020014890123003730607e0060020042a666ae68cdc4000a40002a0a2264a666ae68cdc419b860173370490020008008982999b800170011505230300011505121001130564901013500330194910162005335013105213500422323253350021001105535005223253353001489016300133044002372800c2a66a6002911016200133044002372400c2640b06002910101650030430023335734666e54008c01488d400888d402c88cdc519b8a337146e60c0e801401000ccdc500100080082a029882709826a481016a0050041533550041533533022330231122322300200130532253350011003221330060023004001500348008411c4c1192410168001304649101680030502222533500113303a0030082215333573466e1d20003370c008900209998031982400280119b84004480100044ccc018cc120008014cdc2002240080022a66a601c6aa02244444400c426a00244646464a666ae68cdc3a4000004264646666444424666600200a008006004607c6ae8400cccc111d728219aba100230423574200266082eb8d5d09aba2001357440026ae880044c1292401035054310035573c0046aae74004dd5000898222490134001355010222222004153350061046135500f222222350022235002225323335003215333573466e20094c8cdc099b8048008008004c0f400c414c4c00926130014984c005262350042253335002215333573466e2009cc8cdc019b8100248008004c0fc0084c141240101670010541304f4910167001304f49101670050103041225335001150332215350022233532350032235003225333573466e3c0100084ccd5cd19b870030010460451045355009222222333350012304349010133002304349101330023043491013300200113038004130060032223233001330430043300133043003002040040503e225335001103b133573800407426a002446a0044444444444442466666600201a00a01600600c01c646464a666ae68cdc3a4000004266442466002006004646464a666ae68cdc3a40000042646464646464646464646666666666664444444444442466666666666600201a01801601401201000e00c00a008006004660284646464a666ae68cdc3a400000426644246600200600460686ae84004d5d09aba200113046491035054310035573c0046aae74004dd50009aba100b35742014660284646464a666ae68cdc3a4000004264646666444424666600200a00800600460746ae8400cd5d08011aba1001357426ae88004d5d10009aba2001130464901035054310035573c0046aae74004dd50009aba1009357420106ae8401cd5d08031aba10053232325333573466e1d20000021332212330010030023232325333573466e1d20000021332212330010030023303575a6ae84004c0d0d5d09aba2001130484901035054310035573c0046aae74004dd51aba10013232325333573466e1d20000021332212330010030023303575a6ae84004c0d0d5d09aba2001130484901035054310035573c0046aae74004dd51aba1357440022608a921035054310035573c0046aae74004dd51aba10043301475c6ae8400cd5d08011aba1001357426ae88004d5d10009aba2001357440026ae88004d5d10009aba2001357440026ae88004d5d10009aba200113038491035054310035573c0046aae74004dd51aba10013232325333573466e1d2000002132122223003005375c6ae8400454ccd5cd19b87480080084c848888c004014c098d5d08008a999ab9a3370e900200109909111180100298159aba100115333573466e1d20060021321222230040053232325333573466e1d2000002132122222223005008302e357420022a666ae68cdc3a400400426424444444600e010605c6ae8400454ccd5cd19b87480100084cc8848888888cc018024020c0b8d5d08009bae357426ae8800454ccd5cd19b87480180084cc8848888888cc008024020dd71aba1001375c6ae84d5d10008a999ab9a3370e90040010999109111111198008048041bae357420026eb4d5d09aba200115333573466e1d200a00211222222200415333573466e1d200c0021122222220031303b491035054310035573c0046aae74004dd51aba1001130384901035054310035573c0046aae74004dd51aba1357440022606a921035054310035573c0046aae74004dd5002a99a98010028891000910a99a80089819a4901320022153350011303549101320022153350011303749101320022153350011303949101320022153350011303b49101320022153350011303d49101320022153350011303f49101320022132323333222212233330020060050040033304d3304d5333573466e1d20000011488101620015333573466e1d2002001148810165001489016300375c020a666ae68cdc40012400426086920101320010023304d3041003375a0186609a6eb8028dd7004198269bae006375c0086eb4044dd6806299a980080289818249013100221533500113032491013100221533500113302d375a0086eb8008884c0d9241013100300120012322300237580026074446666aae7c00480e48cd40e0c010d5d080118019aba20020323031225333573466e2400520c00115333573466e2400520800115333573466e24005204015333573466e24005202015333573466e24005201015333573466e1d20100011482001054ccd5cd19b8748038004520800215333573466e1d200c0011482000454ccd5cd19b87480280045204015333573466e1d200800114808054ccd5cd19b87480180045201015333573466e1d200400114802054ccd5cd19b87480080045200415333573466e1d20000011480084cdc11919b820010013002337080029002199ab9a3370e900119b86001480112004480084cdc12410008600466e04005201013370490404004180119b81001480804cdc12410101010040600466e0400520401337049040404040404040404002180119b8100148200044cdc12410101010101010101010101010080600466e0400520c0012233323300123007001330012333573466e1d200000102e02d00423232232223300300200130362225335002130050012215335003130080042213500422323500522533533028005002153353300d00400113300c008006103b15335301000413300c00800a13300353353010001103e133503d00703e006303f2225335001103b221350022253353014001133007006003153353302c009002153353301100800113301000c3330462225335002100122133005001335046002003006003103f133007335041004006003303422533500110302213500222533530080011300600310333033225335001102f22135002225335300700113006003103200200122333573466e1c0080040ac0a88cd5400488cdc0240040029000181691299a80088149109a801112999ab9a3370e9000000898030018816111801999a80200a001000981591299a800881491099a8151a8011119818001199a8009180500091199a80480c80100091805800980200089911119191981811299a800880191099a8178011802000980100218029980311a80091911801003981891299a80088169109a80111299a99806001003881809803001801181711299a800881611099a8169a80111191981a0019800804181a11299a80089aa8198019109a80111299a998078010040891119801005002098030019802000981511299a800881411099a8149a80111198178011817000980200091911801000981511299a800881411099299a9803001899a81500180088009802000981411299a800881311099a8139a801111981680118160009802000981391299a800881291099a8131a80111198160011aa815000980200091801199802a4000002046604a4466a00292100225335001100222135325333573466e24005200013302b028006133500900100633706600e00a90021119b96300800230080013024225335001148000884cdc02400460080026604644244a66a00226604c046046442a666ae68cdc3a400400826605066a04800404a00226a66a600c2400266e040112002001223302a3350260040020011200130222225333573466e20005200012335021491012d003330040033370290000010008980219802810800981091299a8008900091099119a8112999ab9a3370e90000020a4901300015333573466e1d20020041490101310015333573466e1d20040041490101320015333573466e1d20060041490101330015333573466e1d20080041490101340015333573466e1d200a0041490101350015333573466e1d200c0041490101360015333573466e1d200e0041490101370015333573466e1d2010004149010138001333573466e1d2012004490101390049010f3c696e76616c69642064696769743e00300200130040013020222325333573466e1d2000001133501f3370a004900a00189980219a80f99b850024805000c004cdc2000a40284646464a666ae68cdc3a400000420382a666ae68cdc3a4004004203a26030921035054310035573c0046aae74004dd5000911919192999ab9a3370e9000001089110010a999ab9a3370e90010010990911180180218029aba100115333573466e1d20040021122200113018491035054310035573c0046aae74004dd500091919192999ab9a3370e90000010999109198008018011919192999ab9a3370e900000109bae3574200226032921035054310035573c0046aae74004dd51aba1001375a6ae84d5d10008980b2481035054310035573c0046aae74004dd5000919a800a400490001119a801119a801119802801000900c119a801100c11980280100091199ab9a3371e00400202c02a4646464a666ae68cdc3a4000004266442466002006004600c6ae84004cc01c014d5d09aba2001130124901035054310035573c0046aae74004dd500091919192999ab9a3370e9000001099091180100198029aba100115333573466e1d2002002132333222122333001005004003375a6ae84008dd69aba1001375a6ae84d5d10009aba2001130114901035054310035573c0046aae74004dd500091919192999ab9a3370e900000109909118010019bae357420022a666ae68cdc3a4004004260146eb8d5d0800898082481035054310035573c0046aae74004dd5000911919192999ab9a3370e90010010a8058a999ab9a3370e90000010980618029aba1001130104901035054310035573c0046aae74004dd500091919192999ab9a3370e9000001089110018a999ab9a3370e9001001099091118008021bae357420022a666ae68cdc3a4008004264244460040086ae840044c0392401035054310035573c0046aae74004dd5000899800bae75a4464460046eac004c05488cccd55cf800900a11919a80a1980c18031aab9d001300535573c00260086ae8800cd5d080100691091980080180110911800801909118010018891000911b9233714004002601a44a66a002201644266a0186eb8008c010004c030894cd40044028884cd402cd400888cc044008cc0248ccd40048cdc0240000024466e000080048cdc0240000020026008002464c66ae70004008480048c88c008004c028894cd40044020884cd4024d400888cc03c008c01c004c010004c020894cd4004401088c84d400c88c00c004c030894cd40044c01800c884d4008894ccd40048ccd5cd19b884800000440304c01c01088ccd5cd19b88002001100d130080052333573466e200052000100c1300700412200212200112200212212233001004003212223003004112122230010042212330010030022323001001223300330020020013335122233333351222333335122002222222123333330010070060050040030022001005006002122002122001200100200300312200212200120014891c5c6dfd90190d6c66547da3debf3d8967340b270feb3f6cf5a5f8cab80048373df7bcbd18ccd54cd4488008448848cc00400c0084480052211cd441227553a0f1a965fee7d60a0f724b368dd1bddbc208730fccebcf004881054e494748540001' + ), + version: Cardano.PlutusLanguageVersion.V2 + }; + + // 1.- Create reference script input + const scriptRefInput = await createScriptRefInput(wallet, midnightClaimScript); + const scriptHash = Serialization.Script.fromCore(midnightClaimScript).hash(); + const scriptAddress = Cardano.EnterpriseAddress.fromCredentials(Cardano.NetworkId.Testnet, { + hash: scriptHash, + type: Cardano.CredentialType.ScriptHash + }) + .toAddress() + .toBech32() as Cardano.PaymentAddress; + + // 2.- Fund script address + await fundScript(wallet, scriptAddress, midnightDatum); + await walletReady(wallet); + + // 3.- Spend the UTXO and lock the value back in to the script. + const scriptUtxos = await firstValueFrom(getScriptUtxoSet(50, utxoProvider, scriptAddress)); + const bigScriptUtxo = scriptUtxos.find((utxo) => utxo[1].value.coins === 10_000_000n); + expect(bigScriptUtxo).toBeDefined(); + + const txBuilder = wallet.createTxBuilder(); + const txOutput = await txBuilder + .buildOutput() + .address(scriptAddress) + .datum(midnightDatum) + .coin(10_000_000n) + .build(); + + const signedTx = ( + await txBuilder + .addInput(bigScriptUtxo!, { + redeemer: midnightClaimRedeemer + }) + .addReferenceInput(scriptRefInput) + .addOutput(txOutput) + .setValidityInterval({ + invalidBefore: undefined, + invalidHereafter: undefined // HACK: setting any valid interval cause an error in the node: Uncomputable slot arithmetic; transaction's validity bounds go beyond the foreseeable end of the current era + }) + .build() + .sign() + ).tx; + + await wallet.submitTx(signedTx); + + logger.info( + `Submitted transaction id: ${signedTx.id}, inputs: ${JSON.stringify( + signedTx.body.inputs.map((txIn) => [txIn.txId, txIn.index]) + )} and outputs:${JSON.stringify( + signedTx.body.outputs.map((txOut) => [txOut.address, Number.parseInt(txOut.value.coins.toString())]) + )}.` + ); + + const txFoundInHistory = await firstValueFrom( + wallet.transactions.history$.pipe( + map((txs) => txs.find((tx) => tx.id === signedTx.id)), + filter(isNotNil), + take(1) + ) + ); + + logger.info(`Found transaction id in chain history: ${txFoundInHistory.id}`); + + // Assert + expect(txFoundInHistory).toBeDefined(); + expect(txFoundInHistory.id).toEqual(signedTx.id); + }); +}); diff --git a/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts b/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts index 4f90983ee06..cfefca50779 100644 --- a/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts +++ b/packages/input-selection/src/GreedySelection/GreedyInputSelector.ts @@ -11,7 +11,7 @@ import { subtractTokenMaps, toValues } from '../util'; -import { splitChange } from './util'; +import { sortUtxoByTxIn, splitChange } from './util'; /** Greedy selection initialization properties. */ export interface GreedySelectorProps { @@ -48,16 +48,21 @@ const adjustOutputsForFee = async ( outputs: Set, changeOutputs: Array, currentFee: bigint -): Promise<{ fee: bigint; change: Array; feeAccountedFor: boolean }> => { +): Promise<{ + fee: bigint; + change: Array; + feeAccountedFor: boolean; + redeemers?: Array; +}> => { const totalOutputs = new Set([...outputs, ...changeOutputs]); - const fee = await constraints.computeMinimumCost({ + const { fee, redeemers } = await constraints.computeMinimumCost({ change: [], fee: currentFee, inputs, outputs: totalOutputs }); - if (fee === changeLovelace) return { change: [], fee, feeAccountedFor: true }; + if (fee === changeLovelace) return { change: [], fee, feeAccountedFor: true, redeemers }; if (changeLovelace < fee) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); @@ -76,7 +81,7 @@ const adjustOutputsForFee = async ( } } - return { change: [...updatedOutputs], fee, feeAccountedFor }; + return { change: [...updatedOutputs], fee, feeAccountedFor, redeemers }; }; /** @@ -174,8 +179,9 @@ export class GreedyInputSelector implements InputSelector { } async select(params: InputSelectionParameters): Promise { - const { utxo: inputs, outputs, constraints, implicitValue } = params; - const utxoValues = toValues([...inputs]); + const { preSelectedUtxo, utxo: inputs, outputs, constraints, implicitValue } = params; + const allInputs = new Set([...inputs, ...preSelectedUtxo]); + const utxoValues = toValues([...allInputs]); const outputsValues = toValues([...outputs]); const totalLovelaceInUtxoSet = getCoinQuantity(utxoValues); const totalLovelaceInOutputSet = getCoinQuantity(outputsValues); @@ -191,11 +197,11 @@ export class GreedyInputSelector implements InputSelector { const changeLovelace = totalLovelaceInput - totalLovelaceOutput; const changeAssets = subtractTokenMaps(totalAssetsInput, totalAssetsInOutputSet); - if (inputs.size === 0 || totalLovelaceOutput > totalLovelaceInput || hasNegativeAssetValue(changeAssets)) + if (allInputs.size === 0 || totalLovelaceOutput > totalLovelaceInput || hasNegativeAssetValue(changeAssets)) throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); const adjustedChangeOutputs = await splitChangeAndComputeFee( - inputs, + allInputs, outputs, changeLovelace, changeAssets, @@ -212,8 +218,8 @@ export class GreedyInputSelector implements InputSelector { throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); if ( - inputs.size > - (await constraints.computeSelectionLimit({ change, fee: adjustedChangeOutputs.fee, inputs, outputs })) + allInputs.size > + (await constraints.computeSelectionLimit({ change, fee: adjustedChangeOutputs.fee, inputs: allInputs, outputs })) ) { throw new InputSelectionError(InputSelectionFailure.MaximumInputCountExceeded); } @@ -223,7 +229,7 @@ export class GreedyInputSelector implements InputSelector { selection: { change, fee: adjustedChangeOutputs.fee, - inputs, + inputs: new Set([...allInputs].sort(sortUtxoByTxIn)), outputs } }; diff --git a/packages/input-selection/src/GreedySelection/util.ts b/packages/input-selection/src/GreedySelection/util.ts index 14a86a06a8c..191c23ab2e9 100644 --- a/packages/input-selection/src/GreedySelection/util.ts +++ b/packages/input-selection/src/GreedySelection/util.ts @@ -225,3 +225,24 @@ export const splitChange = async ( return distributeAssets(sortedOutputs, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit, fee); }; + +/** + * Sorts the given TxIn set first by txId and then by index. + * + * @param lhs The left-hand side of the comparison operation. + * @param rhs The left-hand side of the comparison operation. + */ +export const sortTxIn = (lhs: Cardano.TxIn, rhs: Cardano.TxIn) => { + const txIdComparison = lhs.txId.localeCompare(rhs.txId); + if (txIdComparison !== 0) return txIdComparison; + + return lhs.index - rhs.index; +}; + +/** + * Sorts the given Utxo set first by TxIn. + * + * @param lhs The left-hand side of the comparison operation. + * @param rhs The left-hand side of the comparison operation. + */ +export const sortUtxoByTxIn = (lhs: Cardano.Utxo, rhs: Cardano.Utxo) => sortTxIn(lhs[0], rhs[0]); diff --git a/packages/input-selection/src/RoundRobinRandomImprove/change.ts b/packages/input-selection/src/RoundRobinRandomImprove/change.ts index 5030e54a8d4..b22cdd3218c 100644 --- a/packages/input-selection/src/RoundRobinRandomImprove/change.ts +++ b/packages/input-selection/src/RoundRobinRandomImprove/change.ts @@ -1,5 +1,5 @@ import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core'; -import { ComputeMinimumCoinQuantity, TokenBundleSizeExceedsLimit } from '../types'; +import { ComputeMinimumCoinQuantity, TokenBundleSizeExceedsLimit, TxCosts } from '../types'; import { InputSelectionError, InputSelectionFailure } from '../InputSelectionError'; import { RequiredImplicitValue, @@ -13,14 +13,14 @@ import minBy from 'lodash/minBy'; import orderBy from 'lodash/orderBy'; import pick from 'lodash/pick'; -type EstimateTxFeeWithOriginalOutputs = (utxo: Cardano.Utxo[], change: Cardano.Value[]) => Promise; +type EstimateTxCostsWithOriginalOutputs = (utxo: Cardano.Utxo[], change: Cardano.Value[]) => Promise; interface ChangeComputationArgs { utxoSelection: UtxoSelection; outputValues: Cardano.Value[]; uniqueTxAssetIDs: Cardano.AssetId[]; implicitValue: RequiredImplicitValue; - estimateTxFee: EstimateTxFeeWithOriginalOutputs; + estimateTxCosts: EstimateTxCostsWithOriginalOutputs; computeMinimumCoinQuantity: ComputeMinimumCoinQuantity; tokenBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit; random: typeof Math.random; @@ -31,6 +31,7 @@ interface ChangeComputationResult { inputs: Cardano.Utxo[]; change: Cardano.Value[]; fee: Cardano.Lovelace; + redeemers?: Array; } const getLeftoverAssets = (utxoSelected: Cardano.Utxo[], uniqueTxAssetIDs: Cardano.AssetId[]) => { @@ -333,7 +334,7 @@ const validateChangeBundles = ( export const computeChangeAndAdjustForFee = async ({ computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit, - estimateTxFee, + estimateTxCosts, outputValues, uniqueTxAssetIDs, implicitValue, @@ -344,7 +345,7 @@ export const computeChangeAndAdjustForFee = async ({ if (currentUtxoSelection.utxoRemaining.length > 0) { return computeChangeAndAdjustForFee({ computeMinimumCoinQuantity, - estimateTxFee, + estimateTxCosts, implicitValue, outputValues, random, @@ -372,13 +373,13 @@ export const computeChangeAndAdjustForFee = async ({ // Calculate fee with change outputs that include fee. // It will cover the fee of final selection, // where fee is excluded from change bundles - const fee = await estimateTxFee( + const estimatedCosts = await estimateTxCosts( selectionWithChangeAndFee.utxoSelected, validateChangeBundles(selectionWithChangeAndFee.changeBundles, tokenBundleSizeExceedsLimit) ); // Ensure fee quantity is covered by current selection - const totalOutputCoin = getCoinQuantity(outputValues) + fee + implicitValue.implicitCoin.deposit; + const totalOutputCoin = getCoinQuantity(outputValues) + estimatedCosts.fee + implicitValue.implicitCoin.deposit; const totalInputCoin = getCoinQuantity(toValues(selectionWithChangeAndFee.utxoSelected)) + implicitValue.implicitCoin.input; if (totalOutputCoin > totalInputCoin) { @@ -391,7 +392,7 @@ export const computeChangeAndAdjustForFee = async ({ const finalSelection = computeChangeBundles({ computeMinimumCoinQuantity, - fee, + fee: estimatedCosts.fee, implicitValue, outputValues, uniqueTxAssetIDs, @@ -406,8 +407,9 @@ export const computeChangeAndAdjustForFee = async ({ return { change: validateChangeBundles(changeBundles, tokenBundleSizeExceedsLimit), - fee, + fee: estimatedCosts.fee, inputs: utxoSelected, + redeemers: estimatedCosts.redeemers, remainingUTxO: utxoRemaining }; }; diff --git a/packages/input-selection/src/RoundRobinRandomImprove/index.ts b/packages/input-selection/src/RoundRobinRandomImprove/index.ts index 1647e28ce4c..466d182a80d 100644 --- a/packages/input-selection/src/RoundRobinRandomImprove/index.ts +++ b/packages/input-selection/src/RoundRobinRandomImprove/index.ts @@ -5,6 +5,7 @@ import { InputSelectionParameters, InputSelector, SelectionResult } from '../typ import { assertIsBalanceSufficient, preProcessArgs, stubMaxSizeAddress, toValues } from '../util'; import { computeChangeAndAdjustForFee } from './change'; import { roundRobinSelection } from './roundRobin'; +import { sortUtxoByTxIn } from '../GreedySelection'; export const MAX_U64 = 18_446_744_073_709_551_615n; @@ -18,33 +19,36 @@ export const roundRobinRandomImprove = ({ random = Math.random }: RoundRobinRandomImproveOptions): InputSelector => ({ select: async ({ + preSelectedUtxo: preSelectedUtxoSet, utxo: utxoSet, outputs: outputSet, constraints: { computeMinimumCost, computeSelectionLimit, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit }, implicitValue: partialImplicitValue = {} }: InputSelectionParameters): Promise => { const changeAddress = stubMaxSizeAddress; - const { utxo, outputs, uniqueTxAssetIDs, implicitValue } = preProcessArgs( + const { requiredUtxo, utxo, outputs, uniqueTxAssetIDs, implicitValue } = preProcessArgs( + preSelectedUtxoSet, utxoSet, outputSet, changeAddress, partialImplicitValue ); - assertIsBalanceSufficient(uniqueTxAssetIDs, utxo, outputs, implicitValue); + assertIsBalanceSufficient(uniqueTxAssetIDs, requiredUtxo, utxo, outputs, implicitValue); const roundRobinSelectionResult = roundRobinSelection({ changeAddress, implicitValue, outputs, random, + requiredUtxo, uniqueTxAssetIDs, utxo }); const result = await computeChangeAndAdjustForFee({ computeMinimumCoinQuantity, - estimateTxFee: (utxos, changeValues) => + estimateTxCosts: (utxos, changeValues) => computeMinimumCost({ change: changeValues.map( (value) => @@ -86,7 +90,10 @@ export const roundRobinRandomImprove = ({ throw new InputSelectionError(InputSelectionFailure.MaximumInputCountExceeded); } + selection.inputs = new Set([...selection.inputs].sort(sortUtxoByTxIn)); + return { + redeemers: result.redeemers, remainingUTxO: new Set(result.remainingUTxO), selection }; diff --git a/packages/input-selection/src/RoundRobinRandomImprove/roundRobin.ts b/packages/input-selection/src/RoundRobinRandomImprove/roundRobin.ts index 3e737737dca..ed29af17427 100644 --- a/packages/input-selection/src/RoundRobinRandomImprove/roundRobin.ts +++ b/packages/input-selection/src/RoundRobinRandomImprove/roundRobin.ts @@ -73,6 +73,7 @@ const listTokensWithin = ( * Considers all outputs collectively, as a combined output bundle. */ export const roundRobinSelection = ({ + requiredUtxo: requiredUtxoWithValue, utxo: utxosWithValue, outputs: outputsWithValue, uniqueTxAssetIDs, @@ -80,7 +81,7 @@ export const roundRobinSelection = ({ implicitValue }: RoundRobinRandomImproveArgs): UtxoSelection => { // The subset of the UTxO that has already been selected: - const utxoSelected: Cardano.Utxo[] = []; + const utxoSelected: Cardano.Utxo[] = requiredUtxoWithValue; // The subset of the UTxO that remains available for selection: const utxoRemaining = [...utxosWithValue]; // The set of tokens that we still need to cover: diff --git a/packages/input-selection/src/types.ts b/packages/input-selection/src/types.ts index 988f9598608..0c645c82c9a 100644 --- a/packages/input-selection/src/types.ts +++ b/packages/input-selection/src/types.ts @@ -35,12 +35,17 @@ export interface SelectionResult { * has removed values to pay for entries in the requested output set. */ remainingUTxO: Set; + + /** The list of redeemers and their execution cost. */ + redeemers?: Array; } +export type TxCosts = { fee: bigint; redeemers?: Array }; + /** * @returns minimum transaction fee in Lovelace. */ -export type EstimateTxFee = (selectionSkeleton: SelectionSkeleton) => Promise; +export type EstimateTxCosts = (selectionSkeleton: SelectionSkeleton) => Promise; /** * @returns true if token bundle size exceeds it's maximum size limit. @@ -59,7 +64,7 @@ export type ComputeMinimumCoinQuantity = (output: Cardano.TxOut) => Cardano.Love export type ComputeSelectionLimit = (selectionSkeleton: SelectionSkeleton) => Promise; export interface SelectionConstraints { - computeMinimumCost: EstimateTxFee; + computeMinimumCost: EstimateTxCosts; tokenBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit; computeMinimumCoinQuantity: ComputeMinimumCoinQuantity; computeSelectionLimit: ComputeSelectionLimit; @@ -74,6 +79,8 @@ export interface ImplicitValue { } export interface InputSelectionParameters { + /** Set of inputs that must be included as part of the final selection. */ + preSelectedUtxo: Set; /** The set of inputs available for selection. */ utxo: Set; /** The set of outputs requested for payment. */ diff --git a/packages/input-selection/src/util.ts b/packages/input-selection/src/util.ts index 630fbed3e6b..8c3ee2a8bd0 100644 --- a/packages/input-selection/src/util.ts +++ b/packages/input-selection/src/util.ts @@ -21,6 +21,7 @@ export interface RequiredImplicitValue { } export interface RoundRobinRandomImproveArgs { + requiredUtxo: Cardano.Utxo[]; utxo: Cardano.Utxo[]; outputs: Cardano.TxOut[]; changeAddress: Cardano.PaymentAddress; @@ -46,6 +47,7 @@ export const mintToImplicitTokens = (mintMap: Cardano.TokenMap = new Map()) => { }; export const preProcessArgs = ( + preSelectedUtxo: Set, availableUtxo: Set, outputSet: Set, changeAddress: Cardano.PaymentAddress, @@ -70,6 +72,7 @@ export const preProcessArgs = ( changeAddress, implicitValue: { implicitCoin, implicitTokens }, outputs, + requiredUtxo: [...preSelectedUtxo], uniqueTxAssetIDs, utxo: [...availableUtxo] }; @@ -116,14 +119,15 @@ export const assertIsCoinBalanceSufficient = ( */ export const assertIsBalanceSufficient = ( uniqueTxAssetIDs: Cardano.AssetId[], + preSelectedUtxo: Cardano.Utxo[], utxo: Cardano.Utxo[], outputs: Cardano.TxOut[], { implicitCoin, implicitTokens }: RequiredImplicitValue ): void => { - if (utxo.length === 0) { + if (preSelectedUtxo.length + utxo.length === 0) { throw new InputSelectionError(InputSelectionFailure.UtxoBalanceInsufficient); } - const utxoValues = toValues(utxo); + const utxoValues = [...toValues(utxo), ...toValues(preSelectedUtxo)]; const outputsValues = toValues(outputs); for (const assetId of uniqueTxAssetIDs) { const getAssetQuantity = assetQuantitySelector(assetId); diff --git a/packages/input-selection/test/GreedySelection/GreedySelection.test.ts b/packages/input-selection/test/GreedySelection/GreedySelection.test.ts index 3b23dd4be86..8ca3abb5477 100644 --- a/packages/input-selection/test/GreedySelection/GreedySelection.test.ts +++ b/packages/input-selection/test/GreedySelection/GreedySelection.test.ts @@ -21,6 +21,8 @@ describe('GreedySelection', () => { ]) }); + const preSelectedUtxo = new Set(); + const utxo = new Set([ TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), @@ -40,6 +42,7 @@ describe('GreedySelection', () => { constraints: mockConstraintsToConstraints(constraints), implicitValue, outputs, + preSelectedUtxo, utxo }); @@ -89,6 +92,82 @@ describe('GreedySelection', () => { }); }); + it('consumes the pre selected inputs plus all available UTXOs in the set and returns that total amount distributed in the change minus the fee', async () => { + const selector = new GreedyInputSelector({ + getChangeAddresses: async () => + new Map([ + [asPaymentAddress('A'), 1], + [asPaymentAddress('B'), 1] + ]) + }); + + const preSelectedUtxo = new Set([ + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }) + ]); + + const utxo = new Set([ + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }) + ]); + const outputs = new Set(); + const expectedFee = 1000n; + const implicitValue = {}; + const constraints = { + ...MOCK_NO_CONSTRAINTS, + minimumCostCoefficient: 100n + }; + + const results = await selector.select({ + constraints: mockConstraintsToConstraints(constraints), + implicitValue, + outputs, + preSelectedUtxo, + utxo + }); + + const { + remainingUTxO, + selection: { change, fee, inputs } + } = results; + + expect(inputs).toEqual(new Set([...utxo, ...preSelectedUtxo])); + expect(remainingUTxO.size).toEqual(0); + expect(fee).toEqual(expectedFee); + + expect(getCoinValueForAddress('A', change)).toEqual(10_000_000n - expectedFee); + expect(getCoinValueForAddress('B', change)).toEqual(10_000_000n); + + expect(change).toEqual([ + { address: 'A', value: { assets: new Map([]), coins: 4_999_000n } }, + { address: 'B', value: { coins: 5_000_000n } }, + { address: 'A', value: { coins: 2_500_000n } }, + { address: 'B', value: { coins: 2_500_000n } }, + { address: 'A', value: { coins: 1_250_000n } }, + { address: 'B', value: { coins: 1_250_000n } }, + { address: 'A', value: { coins: 625_000n } }, + { address: 'B', value: { coins: 625_000n } }, + { address: 'A', value: { coins: 312_500n } }, + { address: 'A', value: { coins: 312_500n } }, + { address: 'B', value: { coins: 312_500n } }, + { address: 'B', value: { coins: 312_500n } } + ]); + + assertInputSelectionProperties({ + constraints, + implicitValue, + outputs, + results, + utxo + }); + }); + it('correctly accounts for outputs and returns the remaining amount distributed in the change minus the fee', async () => { const selector = new GreedyInputSelector({ getChangeAddresses: async () => @@ -101,6 +180,8 @@ describe('GreedySelection', () => { ]) }); + const preSelectedUtxo = new Set(); + const utxo = new Set([ TxTestUtil.createUnspentTxOutput({ coins: 5_000_000n }), TxTestUtil.createUnspentTxOutput({ coins: 4_000_000n }), @@ -126,6 +207,7 @@ describe('GreedySelection', () => { constraints: mockConstraintsToConstraints(constraints), implicitValue, outputs, + preSelectedUtxo, utxo }); @@ -202,6 +284,8 @@ describe('GreedySelection', () => { ]) }); + const preSelectedUtxo = new Set(); + const utxo = new Set([ TxTestUtil.createUnspentTxOutput({ assets: asTokenMap([ @@ -246,6 +330,7 @@ describe('GreedySelection', () => { constraints: mockConstraintsToConstraints(constraints), implicitValue, outputs, + preSelectedUtxo, utxo }); @@ -323,6 +408,7 @@ describe('GreedySelection', () => { ]) }); + const preSelectedUtxo = new Set(); const utxo = new Set([ TxTestUtil.createUnspentTxOutput({ assets: asTokenMap([ @@ -369,6 +455,7 @@ describe('GreedySelection', () => { constraints: mockConstraintsToConstraints(constraints), implicitValue, outputs, + preSelectedUtxo, utxo }); @@ -427,6 +514,7 @@ describe('GreedySelection', () => { it('accounts for implicit coin', async () => { const selector = new GreedyInputSelector({ + // eslint-disable-next-line sonarjs/no-identical-functions getChangeAddresses: async () => new Map([ [asPaymentAddress('A'), 1], @@ -434,6 +522,8 @@ describe('GreedySelection', () => { ]) }); + const preSelectedUtxo = new Set(); + const utxo = new Set([ TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }) @@ -455,6 +545,7 @@ describe('GreedySelection', () => { constraints: mockConstraintsToConstraints(constraints), implicitValue, outputs, + preSelectedUtxo, utxo }); @@ -512,6 +603,7 @@ describe('GreedySelection', () => { }) ]); + const preSelectedUtxo = new Set(); const outputs = new Set(); const expectedFee = 200n; const implicitValue = { @@ -526,6 +618,7 @@ describe('GreedySelection', () => { constraints: mockConstraintsToConstraints(constraints), implicitValue, outputs, + preSelectedUtxo, utxo }); @@ -573,6 +666,7 @@ describe('GreedySelection', () => { }) ]); + const preSelectedUtxo = new Set(); const outputs = new Set(); const expectedFee = 200n; const implicitValue = { @@ -587,6 +681,7 @@ describe('GreedySelection', () => { constraints: mockConstraintsToConstraints(constraints), implicitValue, outputs, + preSelectedUtxo, utxo }); @@ -654,6 +749,7 @@ describe('GreedySelection', () => { }) ]); + const preSelectedUtxo = new Set(); const outputs = new Set(); const implicitValue = { coin: { deposit: 4_000_000n } @@ -670,6 +766,7 @@ describe('GreedySelection', () => { constraints, implicitValue, outputs, + preSelectedUtxo, utxo }); diff --git a/packages/input-selection/test/InputSelectionPropertyTesting.test.ts b/packages/input-selection/test/InputSelectionPropertyTesting.test.ts index e7f3cc41cf0..31cfdb8461c 100644 --- a/packages/input-selection/test/InputSelectionPropertyTesting.test.ts +++ b/packages/input-selection/test/InputSelectionPropertyTesting.test.ts @@ -42,6 +42,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => it('No change', async () => { await testInputSelectionProperties({ createOutputs: () => [TxTestUtil.createOutput({ coins: 3_000_000n })], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n })], getAlgorithm, mockConstraints: SelectionConstraints.MOCK_NO_CONSTRAINTS @@ -51,6 +52,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => // Regression await testInputSelectionProperties({ createOutputs: () => [], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [TxTestUtil.createUnspentTxOutput({ coins: 30_999_994n })], getAlgorithm, mockConstraints: { @@ -64,6 +66,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => // Regression await testInputSelectionProperties({ createOutputs: () => [TxTestUtil.createOutput({ assets: new Map([[AssetId.TSLA, 7001n]]), coins: 1000n })], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [ TxTestUtil.createUnspentTxOutput({ assets: new Map([[AssetId.TSLA, 7001n]]), coins: 11_999_994n }) ], @@ -71,22 +74,58 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => mockConstraints: SelectionConstraints.MOCK_NO_CONSTRAINTS }); }); + + it('can use pre selected inputs', async () => { + await testInputSelectionProperties({ + createOutputs: () => [TxTestUtil.createOutput({ assets: new Map([[AssetId.B, 999n]]), coins: 9_000_000n })], + createPreSelectedOutputUtxo: () => [ + TxTestUtil.createUnspentTxOutput({ assets: new Map([[AssetId.TSLA, 1n]]), coins: 1000n }) + ], + createUtxo: () => [ + TxTestUtil.createUnspentTxOutput({ assets: new Map([[AssetId.B, 999n]]), coins: 99_000_000n }) + ], + getAlgorithm, + mockConstraints: SelectionConstraints.MOCK_NO_CONSTRAINTS + }); + }); + + it('selects pre selected inputs', async () => { + const preSelectedUtxo = new Set([ + TxTestUtil.createUnspentTxOutput({ assets: new Map([[AssetId.TSLA, 1n]]), coins: 1000n }) + ]); + const utxo = new Set([TxTestUtil.createUnspentTxOutput({ coins: 10_000_000n })]); + const outputs = new Set([TxTestUtil.createOutput({ coins: 5_000_000n })]); + const results = await getAlgorithm().select({ + constraints: SelectionConstraints.NO_CONSTRAINTS, + implicitValue: { coin: { input: 2_000_000n } }, + outputs, + preSelectedUtxo, + utxo + }); + expect(results.selection.inputs.size).toBe(2); + expect([...results.selection.inputs].some((output) => output[1].value.assets?.has(AssetId.TSLA))).toBeTruthy(); + }); + it('Selects UTxO even when implicit input covers outputs', async () => { + const preSelectedUtxo = new Set(); const utxo = new Set([TxTestUtil.createUnspentTxOutput({ coins: 10_000_000n })]); const outputs = new Set([TxTestUtil.createOutput({ coins: 1_000_000n })]); const results = await getAlgorithm().select({ constraints: SelectionConstraints.NO_CONSTRAINTS, implicitValue: { coin: { input: 2_000_000n } }, outputs, + preSelectedUtxo, utxo }); expect(results.selection.inputs.size).toBe(1); }); }); + describe('mint', () => { const assetId = AssetId.TSLA; it('Considers positive quantity mint as implicit input', async () => { + const preSelectedUtxo = new Set(); const utxo = new Set([TxTestUtil.createUnspentTxOutput({ coins: 10_000_000n })]); const assets = new Map([[assetId, 100n]]); const outputs = new Set([TxTestUtil.createOutput({ assets, coins: 1_000_000n })]); @@ -94,6 +133,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => constraints: SelectionConstraints.NO_CONSTRAINTS, implicitValue: { mint: new Map(assets.entries()) }, outputs, + preSelectedUtxo, utxo }); expect(results.selection.inputs.size).toBe(1); @@ -109,11 +149,13 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => coins: 10_000_000n }) ]); + const preSelectedUtxo = new Set(); const outputs = new Set([TxTestUtil.createOutput({ coins: 1_000_000n })]); const results = await getAlgorithm().select({ constraints: SelectionConstraints.NO_CONSTRAINTS, implicitValue: { mint: new Map([[assetId, -burnQuantity]]) }, outputs, + preSelectedUtxo, utxo }); expect(results.selection.inputs.size).toBe(1); @@ -130,6 +172,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => TxTestUtil.createOutput({ coins: 12_000_000n }), TxTestUtil.createOutput({ coins: 2_000_000n }) ], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [ TxTestUtil.createUnspentTxOutput({ coins: 3_000_000n }), TxTestUtil.createUnspentTxOutput({ coins: 10_000_000n }) @@ -142,6 +185,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => it('Coin (Outputs+Fee>UTxO)', async () => { await testInputSelectionFailureMode({ createOutputs: () => [TxTestUtil.createOutput({ coins: 9_000_000n })], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [ TxTestUtil.createUnspentTxOutput({ coins: 4_000_000n }), TxTestUtil.createUnspentTxOutput({ coins: 5_000_000n }) @@ -159,6 +203,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => createOutputs: () => [ TxTestUtil.createOutput({ assets: new Map([[AssetId.TSLA, 7001n]]), coins: 5_000_000n }) ], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [ TxTestUtil.createUnspentTxOutput({ assets: new Map([[AssetId.TSLA, 7000n]]), coins: 10_000_000n }) ], @@ -170,6 +215,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => it('No UTxO', async () => { await testInputSelectionFailureMode({ createOutputs: () => [TxTestUtil.createOutput({ coins: 5_000_000n })], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [], expectedError: InputSelectionFailure.UtxoBalanceInsufficient, getAlgorithm, @@ -179,6 +225,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => it('Attempting to burn tokens with insufficient quantity in utxo', async () => { await testInputSelectionFailureMode({ createOutputs: () => [], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [TxTestUtil.createUnspentTxOutput({ coins: 10_000_000n })], expectedError: InputSelectionFailure.UtxoBalanceInsufficient, getAlgorithm, @@ -189,6 +236,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => it('Maximum Input Count Exceeded', async () => { await testInputSelectionFailureMode({ createOutputs: () => [TxTestUtil.createOutput({ coins: 6_000_000n })], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [ TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }), @@ -207,6 +255,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => it('Change bundle value is less than constrained', async () => { await testInputSelectionFailureMode({ createOutputs: () => [TxTestUtil.createOutput({ coins: 2_999_999n })], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [ TxTestUtil.createUnspentTxOutput({ coins: 1_000_000n }), TxTestUtil.createUnspentTxOutput({ coins: 2_000_000n }) @@ -230,6 +279,7 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => coins: 1_000_000n }) ], + createPreSelectedOutputUtxo: () => [], createUtxo: () => [ TxTestUtil.createUnspentTxOutput({ assets: new Map([ @@ -266,12 +316,13 @@ const testInputSelection = (name: string, getAlgorithm: () => InputSelector) => utxoAmounts.map((valueQuantities) => TxTestUtil.createUnspentTxOutput(valueQuantities)) ); const outputs = new Set(outputsAmounts.map((valueQuantities) => TxTestUtil.createOutput(valueQuantities))); - + const preSelectedUtxo = new Set(); try { const results = await algorithm.select({ constraints: SelectionConstraints.mockConstraintsToConstraints(constraints), implicitValue, outputs, + preSelectedUtxo, utxo: new Set(utxo) }); assertInputSelectionProperties({ constraints, implicitValue, outputs, results, utxo }); diff --git a/packages/input-selection/test/RoundRobinRandomImprove.test.ts b/packages/input-selection/test/RoundRobinRandomImprove.test.ts index 195a9931cb9..e8640ec863e 100644 --- a/packages/input-selection/test/RoundRobinRandomImprove.test.ts +++ b/packages/input-selection/test/RoundRobinRandomImprove.test.ts @@ -1,3 +1,4 @@ +import { Cardano } from '@cardano-sdk/core'; import { MockChangeAddressResolver, SelectionConstraints } from './util'; import { TxTestUtil } from '@cardano-sdk/util-dev'; import { roundRobinRandomImprove } from '../src/RoundRobinRandomImprove'; @@ -23,6 +24,7 @@ describe('RoundRobinRandomImprove', () => { */ const random = jest.fn().mockReturnValue(0).mockReturnValueOnce(0).mockReturnValueOnce(0.99); + const preSelectedUtxo = new Set(); const results = await roundRobinRandomImprove({ changeAddressResolver: new MockChangeAddressResolver(), random @@ -33,9 +35,47 @@ describe('RoundRobinRandomImprove', () => { minimumCostCoefficient: 200_000n }), outputs, + preSelectedUtxo, utxo }); expect(results.selection.inputs.size).toBe(3); expect(results.selection.fee).toBe(600_000n); }); + + it('Always select the preSelected input', async () => { + const utxo = new Set([ + TxTestUtil.createUnspentTxOutput({ coins: 1_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 1_000_000n }), + TxTestUtil.createUnspentTxOutput({ coins: 1_000_000n }) + ]); + + const mockForeignInput = TxTestUtil.createUnspentTxOutput({ coins: 2_000_111n }); + const preSelectedUtxo = new Set([mockForeignInput]); + const outputs = new Set([TxTestUtil.createOutput({ coins: 1_000_000n })]); + const random = jest.fn().mockReturnValue(0).mockReturnValueOnce(0).mockReturnValueOnce(0.99); + + const results = await roundRobinRandomImprove({ + changeAddressResolver: new MockChangeAddressResolver(), + random + }).select({ + constraints: SelectionConstraints.mockConstraintsToConstraints({ + ...SelectionConstraints.MOCK_NO_CONSTRAINTS, + minimumCoinQuantity: 900_000n, + minimumCostCoefficient: 200_000n + }), + outputs, + preSelectedUtxo, + utxo + }); + expect(results.selection.inputs.size).toBe(2); + expect(results.selection.fee).toBe(400_000n); + expect( + [...results.selection.inputs.values()].some( + (value) => + value[0].txId === mockForeignInput[0].txId && + value[0].index === mockForeignInput[0].index && + value[1].value.coins === mockForeignInput[1].value.coins + ) + ).toBeTruthy(); + }); }); diff --git a/packages/input-selection/test/util/selectionConstraints.ts b/packages/input-selection/test/util/selectionConstraints.ts index a281481efbb..acc2b18dcea 100644 --- a/packages/input-selection/test/util/selectionConstraints.ts +++ b/packages/input-selection/test/util/selectionConstraints.ts @@ -17,7 +17,7 @@ export const MOCK_NO_CONSTRAINTS: MockSelectionConstraints = { export const mockConstraintsToConstraints = (constraints: MockSelectionConstraints): SelectionConstraints => ({ computeMinimumCoinQuantity: () => constraints.minimumCoinQuantity, - computeMinimumCost: async ({ inputs }) => constraints.minimumCostCoefficient * BigInt(inputs.size), + computeMinimumCost: async ({ inputs }) => ({ fee: constraints.minimumCostCoefficient * BigInt(inputs.size) }), computeSelectionLimit: async () => constraints.selectionLimit, tokenBundleSizeExceedsLimit: (assets?: Cardano.TokenMap) => (assets?.size || 0) > constraints.maxTokenBundleSize }); diff --git a/packages/input-selection/test/util/tests.ts b/packages/input-selection/test/util/tests.ts index 1e8cd6fbcef..5430b5bc900 100644 --- a/packages/input-selection/test/util/tests.ts +++ b/packages/input-selection/test/util/tests.ts @@ -7,6 +7,8 @@ import { assertInputSelectionProperties } from './properties'; export interface InputSelectionPropertiesTestParams { /** Test subject (Input Selection algorithm under test) */ getAlgorithm: () => InputSelector; + /** Outputs that must always be included in the selection */ + createPreSelectedOutputUtxo: () => Cardano.Utxo[]; /** Available UTxO */ createUtxo: () => Cardano.Utxo[]; /** Transaction outputs */ @@ -31,6 +33,7 @@ export const testInputSelectionFailureMode = async ({ expectedError, mockConstraints }: InputSelectionFailureModeTestParams) => { + const preSelectedUtxo = new Set(); const utxo = new Set(createUtxo()); const outputs = new Set(createOutputs()); const algorithm = getAlgorithm(); @@ -39,6 +42,7 @@ export const testInputSelectionFailureMode = async ({ constraints: SelectionConstraints.mockConstraintsToConstraints(mockConstraints), implicitValue, outputs, + preSelectedUtxo, utxo }) ).rejects.toThrowError(new InputSelectionError(expectedError)); @@ -52,6 +56,7 @@ export const testInputSelectionProperties = async ({ implicitValue, mockConstraints }: InputSelectionPropertiesTestParams) => { + const preSelectedUtxo = new Set(); const utxo = new Set(createUtxo()); const outputs = new Set(createOutputs()); const algorithm = getAlgorithm(); @@ -59,6 +64,7 @@ export const testInputSelectionProperties = async ({ constraints: SelectionConstraints.mockConstraintsToConstraints(mockConstraints), implicitValue, outputs, + preSelectedUtxo, utxo }); assertInputSelectionProperties({ constraints: mockConstraints, outputs, results, utxo }); diff --git a/packages/tx-construction/src/createTransactionInternals.ts b/packages/tx-construction/src/createTransactionInternals.ts index fb93719f2bc..dc3521512e8 100644 --- a/packages/tx-construction/src/createTransactionInternals.ts +++ b/packages/tx-construction/src/createTransactionInternals.ts @@ -2,14 +2,18 @@ import * as Crypto from '@cardano-sdk/crypto'; import { Cardano, Serialization, util } from '@cardano-sdk/core'; import { SelectionResult } from '@cardano-sdk/input-selection'; import { TxBodyPreInputSelection } from './types'; +import { computeScriptDataHash } from './computeScriptDataHash'; +import { getDefaultCostModelsForVersions } from './tx-builder/costModels'; export type CreateTxInternalsProps = { inputSelection: SelectionResult['selection']; + referenceInputs?: Set; validityInterval: Cardano.ValidityInterval; certificates?: Cardano.Certificate[]; withdrawals?: Cardano.Withdrawal[]; auxiliaryData?: Cardano.AuxiliaryData; collaterals?: Set; + collateralReturn?: Cardano.TxOut; mint?: Cardano.TokenMap; scriptIntegrityHash?: Crypto.Hash32ByteBase16; requiredExtraSignatures?: Crypto.Ed25519KeyHashHex[]; @@ -22,8 +26,10 @@ export const createPreInputSelectionTxBody = ({ certificates, validityInterval, collaterals, + collateralReturn, mint, scriptIntegrityHash, + referenceInputs, requiredExtraSignatures, outputs }: Omit & { outputs?: Cardano.TxOut[] }): { @@ -36,20 +42,26 @@ export const createPreInputSelectionTxBody = ({ certificates, mint, outputs: outputs || [], + referenceInputs: referenceInputs ? [...referenceInputs] : undefined, requiredExtraSignatures, scriptIntegrityHash, validityInterval, ...(withdrawals?.length && { withdrawals }), - ...(collaterals?.size && { collaterals: [...collaterals] }) + ...(collaterals?.size && { collaterals: [...collaterals] }), + collateralReturn } }); /** Updates the txBody after input selection takes place with the calculated change and selected inputs */ export const includeChangeAndInputs = ({ bodyPreInputSelection, - inputSelection + inputSelection, + scriptVersions, + witness }: Pick & { bodyPreInputSelection: TxBodyPreInputSelection; + witness?: Cardano.Witness; + scriptVersions?: Set; }): Cardano.TxBodyWithHash => { const body: Cardano.TxBody = { ...bodyPreInputSelection, @@ -57,6 +69,17 @@ export const includeChangeAndInputs = ({ inputs: [...inputSelection.inputs].map(([txIn]) => txIn), outputs: [...inputSelection.outputs, ...inputSelection.change] }; + + if (scriptVersions && witness) { + const costModels = getDefaultCostModelsForVersions([...scriptVersions]); + body.scriptIntegrityHash = computeScriptDataHash( + costModels, + [...scriptVersions], + witness.redeemers, + witness.datums + ); + } + const serializableBody = Serialization.TransactionBody.fromCore(body); return { diff --git a/packages/tx-construction/src/input-selection/selectionConstraints.ts b/packages/tx-construction/src/input-selection/selectionConstraints.ts index 02ebc266de3..81b4d7e62ed 100644 --- a/packages/tx-construction/src/input-selection/selectionConstraints.ts +++ b/packages/tx-construction/src/input-selection/selectionConstraints.ts @@ -2,7 +2,7 @@ import { Cardano, InvalidProtocolParametersError, Serialization } from '@cardano import { ComputeMinimumCoinQuantity, ComputeSelectionLimit, - EstimateTxFee, + EstimateTxCosts, ProtocolParametersForInputSelection, ProtocolParametersRequiredByInputSelection, SelectionConstraints, @@ -10,16 +10,98 @@ import { TokenBundleSizeExceedsLimit } from '@cardano-sdk/input-selection'; import { MinFeeCoefficient, MinFeeConstant, minAdaRequired, minFee } from '../fees'; +import { TxEvaluationResult, TxEvaluator, TxIdWithIndex } from '../tx-builder'; export const MAX_U64 = 18_446_744_073_709_551_615n; export type BuildTx = (selection: SelectionSkeleton) => Promise; +export interface RedeemersByType { + spend?: Map; + mint?: Array; + certificate?: Array; + withdrawal?: Array; + propose?: Array; + vote?: Array; +} + export interface DefaultSelectionConstraintsProps { protocolParameters: ProtocolParametersForInputSelection; buildTx: BuildTx; + redeemersByType: RedeemersByType; + txEvaluator: TxEvaluator; } +const updateRedeemers = ( + evaluation: TxEvaluationResult, + redeemersByType: RedeemersByType, + txInputs: Array +): Array => { + const result: Array = []; + + // Mapping between purpose and redeemersByType + const redeemersMap: { [key in Cardano.RedeemerPurpose]?: Map | Cardano.Redeemer[] } = { + [Cardano.RedeemerPurpose.spend]: redeemersByType.spend, + [Cardano.RedeemerPurpose.mint]: redeemersByType.mint, + [Cardano.RedeemerPurpose.certificate]: redeemersByType.certificate, + [Cardano.RedeemerPurpose.withdrawal]: redeemersByType.withdrawal, + [Cardano.RedeemerPurpose.propose]: redeemersByType.propose, + [Cardano.RedeemerPurpose.vote]: redeemersByType.vote + }; + + for (const txEval of evaluation) { + const redeemers = redeemersMap[txEval.purpose]; + if (!redeemers) throw new Error(`No redeemers found for ${txEval.purpose} purpose`); + + let knownRedeemer; + if (txEval.purpose === Cardano.RedeemerPurpose.spend) { + const input = txInputs[txEval.index]; + + knownRedeemer = (redeemers as Map).get(`${input.txId}#${input.index}`); + + if (!knownRedeemer) throw new Error(`Known Redeemer not found for tx id ${input.txId} and index ${input.index}`); + } else { + const redeemerList = redeemers as Cardano.Redeemer[]; + + knownRedeemer = redeemerList.find((redeemer) => redeemer.index === txEval.index); + + if (!knownRedeemer) throw new Error(`Known Redeemer not found for index ${txEval.index}`); + } + + result.push({ ...knownRedeemer, executionUnits: txEval.budget }); + } + + return result; +}; + +const reorgRedeemers = ( + redeemerByType: RedeemersByType, + witness: Cardano.Witness, + txInputs: Array +): Cardano.Redeemer[] => { + let redeemers: Cardano.Redeemer[] = []; + + if (witness.redeemers) { + // Lets remove all spend redeemers if any. + redeemers = witness.redeemers.filter((redeemer) => redeemer.purpose !== Cardano.RedeemerPurpose.spend); + + // Add them back with the correct redeemer index. + if (redeemerByType.spend) { + for (const [key, value] of redeemerByType.spend) { + const index = txInputs.findIndex((input) => key === `${input.txId}#${input.index}`); + + if (index < 0) throw new Error(`Redeemer not found for tx id ${key}`); + + value.index = index; + + redeemers.push({ ...value }); + } + } + } + + return redeemers; +}; + export const computeMinimumCost = ( { @@ -27,12 +109,25 @@ export const computeMinimumCost = minFeeConstant, prices }: Pick, - buildTx: BuildTx - ): EstimateTxFee => + buildTx: BuildTx, + txEvaluator: TxEvaluator, + redeemersByType: RedeemersByType + ): EstimateTxCosts => async (selection) => { const tx = await buildTx(selection); + const utxos = [...selection.inputs]; + const txIns = utxos.map((utxo) => utxo[0]); + + if (tx.witness && tx.witness.redeemers && tx.witness.redeemers.length > 0) { + // before the evaluation can happen, we need to point every redeemer to its corresponding inputs. + tx.witness.redeemers = reorgRedeemers(redeemersByType, tx.witness, txIns); + tx.witness.redeemers = updateRedeemers(await txEvaluator.evaluate(tx, utxos), redeemersByType, txIns); + } - return minFee(tx, prices, MinFeeConstant(minFeeConstant), MinFeeCoefficient(minFeeCoefficient)); + return { + fee: minFee(tx, prices, MinFeeConstant(minFeeConstant), MinFeeCoefficient(minFeeCoefficient)), + redeemers: tx.witness.redeemers + }; }; export const computeMinimumCoinQuantity = @@ -75,7 +170,9 @@ export const computeSelectionLimit = export const defaultSelectionConstraints = ({ protocolParameters: { coinsPerUtxoByte, maxTxSize, maxValueSize, minFeeCoefficient, minFeeConstant, prices }, - buildTx + buildTx, + redeemersByType, + txEvaluator }: DefaultSelectionConstraintsProps): SelectionConstraints => { if (!coinsPerUtxoByte || !maxTxSize || !maxValueSize || !minFeeCoefficient || !minFeeConstant || !prices) { throw new InvalidProtocolParametersError( @@ -84,7 +181,12 @@ export const defaultSelectionConstraints = ({ } return { computeMinimumCoinQuantity: computeMinimumCoinQuantity(coinsPerUtxoByte), - computeMinimumCost: computeMinimumCost({ minFeeCoefficient, minFeeConstant, prices }, buildTx), + computeMinimumCost: computeMinimumCost( + { minFeeCoefficient, minFeeConstant, prices }, + buildTx, + txEvaluator, + redeemersByType + ), computeSelectionLimit: computeSelectionLimit(maxTxSize, buildTx), tokenBundleSizeExceedsLimit: tokenBundleSizeExceedsLimit(maxValueSize) }; diff --git a/packages/tx-construction/src/tx-builder/GreedyTxEvaluator.ts b/packages/tx-construction/src/tx-builder/GreedyTxEvaluator.ts new file mode 100644 index 00000000000..9e574c04170 --- /dev/null +++ b/packages/tx-construction/src/tx-builder/GreedyTxEvaluator.ts @@ -0,0 +1,44 @@ +import { Cardano } from '@cardano-sdk/core'; +import { TxEvaluationResult, TxEvaluator } from './types'; + +/* + * This evaluator assigns the maximum execution units per transaction to each redeemer. + */ +export class GreedyTxEvaluator implements TxEvaluator { + #params: Promise; + + /** + * Creates an instance of GreedyTxEvaluator. + * + * @param getProtocolParams - A callback that resolves to the Cardano protocol parameters. + */ + constructor(getProtocolParams: () => Promise) { + this.#params = getProtocolParams(); + } + + /** + * Evaluates a transaction and assigns the maximum execution units per transaction to each redeemer. + * + * @param tx - The transaction to be evaluated. + * @param _ - The list of UTXOs (not used in this implementation). + * @returns A promise that resolves to the transaction evaluation result. + */ + async evaluate(tx: Cardano.Tx, _: Array): Promise { + const { maxExecutionUnitsPerTransaction } = await this.#params; + const { witness } = tx; + + if (!witness || !witness.redeemers) return []; + + const result: TxEvaluationResult = []; + + for (const redeemer of witness.redeemers) { + result.push({ + budget: maxExecutionUnitsPerTransaction, + index: redeemer.index, + purpose: redeemer.purpose + }); + } + + return result; + } +} diff --git a/packages/tx-construction/src/tx-builder/OutputBuilder.ts b/packages/tx-construction/src/tx-builder/OutputBuilder.ts index 625bd541294..2662fe14df0 100644 --- a/packages/tx-construction/src/tx-builder/OutputBuilder.ts +++ b/packages/tx-construction/src/tx-builder/OutputBuilder.ts @@ -1,4 +1,4 @@ -import { Cardano, Handle, HandleProvider } from '@cardano-sdk/core'; +import { Cardano, Handle, HandleProvider, Serialization } from '@cardano-sdk/core'; import { Hash32ByteBase16 } from '@cardano-sdk/crypto'; import { Logger } from 'ts-log'; @@ -125,8 +125,21 @@ export class TxOutputBuilder implements OutputBuilder { return this; } - datum(datumHash: Hash32ByteBase16): TxOutputBuilder { - this.#partialOutput = { ...this.#partialOutput, datumHash }; + datum(datum: Hash32ByteBase16 | Cardano.PlutusData): TxOutputBuilder { + if (Serialization.isDatumHash(datum)) { + this.#partialOutput = { ...this.#partialOutput, datumHash: datum }; + } else { + this.#partialOutput = { ...this.#partialOutput, datum }; + } + + return this; + } + + scriptReference(script: Cardano.Script) { + if (!Cardano.isPlutusScript(script)) throw new Error('Only plutus scripts can be added as reference scripts.'); + + this.#partialOutput = { ...this.#partialOutput, scriptReference: script }; + return this; } @@ -136,6 +149,7 @@ export class TxOutputBuilder implements OutputBuilder { } this.#partialOutput = { ...this.#partialOutput, handle }; + return this; } diff --git a/packages/tx-construction/src/tx-builder/TxBuilder.ts b/packages/tx-construction/src/tx-builder/TxBuilder.ts index 0c91730e87d..1126f4374af 100644 --- a/packages/tx-construction/src/tx-builder/TxBuilder.ts +++ b/packages/tx-construction/src/tx-builder/TxBuilder.ts @@ -3,7 +3,6 @@ import * as Crypto from '@cardano-sdk/crypto'; import { AddressType, Bip32Account, - GroupedAddress, SignTransactionOptions, TransactionSigner, WitnessedTx, @@ -13,31 +12,43 @@ import { Cardano, HandleProvider, HandleResolution, Serialization, metadatum } f import { CustomizeCb, InsufficientRewardAccounts, - InvalidHereafterError, OutOfSyncRewardAccounts, OutputBuilderTxOut, PartialTx, PartialTxOut, + ScriptUnlockProps, TxBuilder, TxBuilderDependencies, TxContext, + TxEvaluator, TxInspection, TxOutValidationError, UnwitnessedTx } from './types'; -import { GreedyInputSelector, SelectionSkeleton } from '@cardano-sdk/input-selection'; +import { GreedyTxEvaluator } from './GreedyTxEvaluator'; import { Logger } from 'ts-log'; import { OutputBuilderValidator, TxOutputBuilder } from './OutputBuilder'; +import { RedeemersByType } from '../input-selection'; import { RewardAccountWithPoolId } from '../types'; +import { + RewardAccountsAndWeights, + buildWitness, + computeCollateral, + createGreedyInputSelector, + sortRewardAccountsDelegatedFirst, + validateValidityInterval +} from './utils'; +import { SelectionSkeleton } from '@cardano-sdk/input-selection'; import { coldObservableProvider } from '@cardano-sdk/util-rxjs'; import { contextLogger, deepEquals } from '@cardano-sdk/util'; import { createOutputValidator } from '../output-validation'; import { initializeTx } from './initializeTx'; import { lastValueFrom } from 'rxjs'; -import minBy from 'lodash/minBy'; import omit from 'lodash/omit'; import uniq from 'lodash/uniq'; +const DUMMY_SCRIPT_DATA_HASH = '0'.repeat(64) as unknown as Crypto.Hash32ByteBase16; + type BuiltTx = { tx: Cardano.TxBodyWithHash; ctx: TxContext; @@ -58,7 +69,6 @@ interface LazySignerProps { } type TxBuilderStakePool = Omit & { id: Cardano.PoolId }; -type RewardAccountsAndWeights = Map; class LazyTxSigner implements UnwitnessedTx { #built?: BuiltTx; @@ -79,11 +89,20 @@ class LazyTxSigner implements UnwitnessedTx { tx, ctx: { signingContext: { knownAddresses, handleResolutions }, - auxiliaryData + auxiliaryData, + witness }, inputSelection } = await this.#build(); - return { ...tx, auxiliaryData, handleResolutions, inputSelection, ownAddresses: knownAddresses }; + + return { + ...tx, + auxiliaryData, + handleResolutions, + inputSelection, + ownAddresses: knownAddresses, + witness: witness as Cardano.Witness + }; } async sign(): Promise { @@ -91,6 +110,8 @@ class LazyTxSigner implements UnwitnessedTx { } } +export type TxIdWithIndex = string; + export class GenericTxBuilder implements TxBuilder { partialTxBody: Partial = {}; partialAuxiliaryData?: Cardano.AuxiliaryData; @@ -100,11 +121,31 @@ export class GenericTxBuilder implements TxBuilder { #dependencies: TxBuilderDependencies; #outputValidator: OutputBuilderValidator; #requestedPortfolio?: TxBuilderStakePool[]; + #txEvaluator: TxEvaluator; #logger: Logger; #handleProvider?: HandleProvider; #handleResolutions: HandleResolution[]; #delegateFirstStakeCredConfig: Cardano.PoolId | null | undefined = undefined; + #preSelectedInputs = new Map(); + #referenceInputs = new Map(); + #knownScripts = new Map(); + #knownReferenceScripts = new Set(); + #knownDatums = new Map(); + #knownInlineDatums = new Set(); + #knownRedeemers: RedeemersByType = { + certificate: new Array(), + mint: new Array(), + propose: new Array(), + spend: new Map(), + vote: new Array(), + withdrawal: new Array() + }; + + #unresolvedInputs = new Array(); + #unresolvedReferenceInputs = new Array(); + #unresolvedDatums = new Array(); + #customizeCb: CustomizeCb; constructor(dependencies: TxBuilderDependencies) { @@ -117,6 +158,8 @@ export class GenericTxBuilder implements TxBuilder { this.#logger = dependencies.logger; this.#handleProvider = dependencies.handleProvider; this.#handleResolutions = []; + this.#txEvaluator = + dependencies.txEvaluator ?? new GreedyTxEvaluator(dependencies.txBuilderProviders.protocolParameters); } async inspect(): Promise { @@ -128,6 +171,55 @@ export class GenericTxBuilder implements TxBuilder { }; } + addReferenceInput(input: Cardano.TxIn | Cardano.Utxo): TxBuilder { + if (Array.isArray(input)) { + const inputId: TxIdWithIndex = `${input[0].txId}#${input[0].index}`; + this.#referenceInputs.set(inputId, input); + + return this; + } + + if ( + !this.#unresolvedReferenceInputs.some( + (unresolvedInput) => unresolvedInput.txId === input.txId && unresolvedInput.index === input.index + ) + ) + this.#unresolvedReferenceInputs.push(input); + + return this; + } + + addInput(input: Cardano.TxIn | Cardano.Utxo, scriptUnlockProps?: ScriptUnlockProps): TxBuilder { + if (scriptUnlockProps) { + this.#addScriptInput(input, scriptUnlockProps); + return this; + } + + if (Array.isArray(input)) { + const inputId: TxIdWithIndex = `${input[0].txId}#${input[0].index}`; + this.#preSelectedInputs.set(inputId, input); + + return this; + } + + if ( + !this.#unresolvedInputs.some( + (unresolvedInput) => unresolvedInput.txId === input.txId && unresolvedInput.index === input.index + ) + ) + this.#unresolvedInputs.push(input); + + return this; + } + + addDatum(datum: Cardano.PlutusData): TxBuilder { + const hash = Serialization.PlutusData.fromCore(datum).hash(); + + this.#knownDatums.set(hash, datum); + + return this; + } + addOutput(txOut: OutputBuilderTxOut): TxBuilder { if (txOut.handleResolution) { this.#handleResolutions = [...this.#handleResolutions, txOut.handleResolution]; @@ -213,7 +305,7 @@ export class GenericTxBuilder implements TxBuilder { build(): UnwitnessedTx { return new LazyTxSigner({ builder: { - // eslint-disable-next-line sonarjs/cognitive-complexity + // eslint-disable-next-line sonarjs/cognitive-complexity,max-statements build: async () => { this.#logger.debug('Building'); try { @@ -222,7 +314,12 @@ export class GenericTxBuilder implements TxBuilder { : await this.#delegateFirstStakeCredential(); await this.#validateOutputs(); - await this.#validateValidityInterval(); + + validateValidityInterval( + await this.#dependencies.txBuilderProviders.tip(), + this.partialTxBody.validityInterval + ); + // Take a snapshot of returned properties, // so that they don't change while `initializeTx` is resolving const ownAddresses = await this.#dependencies.txBuilderProviders.addresses.get(); @@ -262,27 +359,69 @@ export class GenericTxBuilder implements TxBuilder { // Distributing balance according to weights is necessary when there are multiple reward accounts // and delegating, to make sure utxos are part of the correct addresses (the ones being delegated) - dependencies.inputSelector = GenericTxBuilder.#createGreedyInputSelector( - rewardAccountsWithWeights, - ownAddresses - ); + dependencies.inputSelector = createGreedyInputSelector(rewardAccountsWithWeights, ownAddresses); + } + + // Resolved all unresolved inputs + await Promise.all( + this.#unresolvedInputs.map((input) => this.#resolveInput(input, this.#preSelectedInputs)) + ); + await Promise.all( + this.#unresolvedReferenceInputs.map((input) => this.#resolveInput(input, this.#referenceInputs)) + ); + + // We must resolve datums after inputs since we may discover datums during that process. + await Promise.all(this.#unresolvedDatums.map((datumHash) => this.#resolveDatum(datumHash))); + + const witness = await buildWitness( + this.#knownScripts, + this.#knownReferenceScripts, + this.#knownDatums, + this.#knownInlineDatums, + this.#knownRedeemers, + this.#dependencies.txBuilderProviders + ); + + const hasPlutusScripts = [...this.#knownScripts.values()].some((script) => Cardano.isPlutusScript(script)); + + const { collaterals, collateralReturn } = hasPlutusScripts + ? await computeCollateral(this.#dependencies.txBuilderProviders) + : { collateralReturn: undefined, collaterals: undefined }; + + const scriptVersions = new Set(); + for (const script of this.#knownScripts.values()) { + if (Cardano.isPlutusScript(script)) { + scriptVersions.add(script.version); + } } - const { body, hash, inputSelection } = await initializeTx( + const { body, hash, inputSelection, redeemers } = await initializeTx( { auxiliaryData, certificates: this.partialTxBody.certificates, + collateralReturn, + collaterals, customizeCb: this.#customizeCb, handleResolutions: this.#handleResolutions, + inputs: new Set(this.#preSelectedInputs.values()), options: { validityInterval: this.partialTxBody.validityInterval }, outputs: new Set(this.partialTxBody.outputs || []), proposalProcedures: this.partialTxBody.proposalProcedures, - signingOptions: partialSigningOptions + redeemersByType: this.#knownRedeemers, + referenceInputs: new Set([...this.#referenceInputs.values()].map((utxo) => utxo[0])), + scriptIntegrityHash: hasPlutusScripts ? DUMMY_SCRIPT_DATA_HASH : undefined, + scriptVersions, + signingOptions: partialSigningOptions, + txEvaluator: this.#txEvaluator, + witness }, dependencies ); + + witness.redeemers = redeemers; + return { ctx: { auxiliaryData, @@ -292,7 +431,8 @@ export class GenericTxBuilder implements TxBuilder { knownAddresses: ownAddresses, txInKeyPathMap: await util.createTxInKeyPathMap(body, ownAddresses, this.#dependencies.inputResolver) }, - signingOptions: { ...partialSigningOptions, extraSigners } + signingOptions: { ...partialSigningOptions, extraSigners }, + witness }, inputSelection, tx: { body, hash } @@ -308,9 +448,7 @@ export class GenericTxBuilder implements TxBuilder { sign: ({ tx, ctx }) => { const transaction = new Serialization.Transaction( Serialization.TransactionBody.fromCore(tx.body), - Serialization.TransactionWitnessSet.fromCore( - ctx.witness ? (ctx.witness as Cardano.Witness) : { signatures: new Map() } - ), + Serialization.TransactionWitnessSet.fromCore((ctx.witness ?? { signatures: new Map() }) as Cardano.Witness), ctx.auxiliaryData ? Serialization.AuxiliaryData.fromCore(ctx.auxiliaryData) : undefined ); @@ -330,17 +468,6 @@ export class GenericTxBuilder implements TxBuilder { return this; } - async #validateValidityInterval(): Promise { - if (!this.partialTxBody.validityInterval?.invalidHereafter) { - return; - } - - const tip = await this.#dependencies.txBuilderProviders.tip(); - if (tip.slot >= this.partialTxBody.validityInterval.invalidHereafter) { - throw new InvalidHereafterError(); - } - } - /** @throws {TxOutValidationError[]} TxOutValidationError[] in case of validation errors */ async #validateOutputs(): Promise { if (this.partialTxBody.outputs) { @@ -487,7 +614,7 @@ export class GenericTxBuilder implements TxBuilder { !rewardAccount.delegatee?.nextNextEpoch || this.#requestedPortfolio?.every(({ id }) => id !== rewardAccount.delegatee?.nextNextEpoch?.id) ) - .sort(GenericTxBuilder.#sortRewardAccountsDelegatedFirst) + .sort(sortRewardAccountsDelegatedFirst) .reverse(); // items will be popped from this array, so we want the most suitable at the end of the array if (newPools.length > availableRewardAccounts.length) { @@ -521,50 +648,100 @@ export class GenericTxBuilder implements TxBuilder { return rewardAccountsWithWeights; } - /** Registered and delegated < Registered < Unregistered */ - static #sortRewardAccountsDelegatedFirst(a: RewardAccountWithPoolId, b: RewardAccountWithPoolId): number { - const getScore = (acct: RewardAccountWithPoolId) => { - let score = 2; - if (acct.credentialStatus === Cardano.StakeCredentialStatus.Registered) { - score = 1; - if (acct.delegatee?.nextNextEpoch) { - score = 0; - } + async #resolveDatum(datumHash: Cardano.DatumHash) { + // Lets check first if the datum was not added independently to the builder via addDatum. + if (this.#knownDatums.has(datumHash)) return; + + if (!this.#dependencies.datumResolver) throw new Error('Cant resolve unknown datums. Datum resolver not set.'); + + const datum = await this.#dependencies.datumResolver.resolve(datumHash); + + if (!datum) throw new Error(`Could not resolve datum with datum hash ${datumHash}`); + + this.#knownDatums.set(datumHash, datum); + } + + async #resolveInput(input: Cardano.TxIn, inputs: Map) { + const inputId: TxIdWithIndex = `${input.txId}#${input.index}`; + + const resolvedInput = await this.#dependencies.inputResolver.resolveInput(input); + + if (!resolvedInput) throw new Error(`Could not resolve input ${inputId}`); + + if (resolvedInput.scriptReference) { + const policyId = Serialization.Script.fromCore(resolvedInput.scriptReference).hash(); + + this.#knownScripts.set(policyId, resolvedInput.scriptReference); + this.#knownReferenceScripts.add(policyId); + } + + const datum = resolvedInput.datum; + + if (datum) { + if (Serialization.isDatumHash(datum)) { + if (!this.#knownDatums.has(datum)) this.#unresolvedDatums.push(datum); + } else { + const hash = Serialization.PlutusData.fromCore(datum).hash(); + this.#knownDatums.set(hash, datum); + this.#knownInlineDatums.add(hash); // We need to keep track of which datums are inline vs the ones provided } - return score; - }; + } - return getScore(a) - getScore(b); + inputs.set(inputId, [{ ...input, address: resolvedInput.address }, resolvedInput]); } - /** - * Searches the payment address with the smallest index associated to the reward accounts. - * - * @param rewardAccountsWithWeights reward account addresses and the portfolio distribution weights. - * @param ownAddresses addresses to search in by reward account. - * @returns GreedyInputSelector with the addresses and weights to use as change addresses. - * @throws in case some reward accounts are not associated with any of the own addresses - */ - static #createGreedyInputSelector( - rewardAccountsWithWeights: RewardAccountsAndWeights, - ownAddresses: GroupedAddress[] - ) { - // select the address with smallest index for each reward account - const addressesAndWeights = new Map( - [...rewardAccountsWithWeights].map(([rewardAccount, weight]) => { - const address = minBy( - ownAddresses.filter((ownAddr) => ownAddr.rewardAccount === rewardAccount), - ({ index }) => index - ); - if (!address) { - throw new Error(`Could not find any address associated with ${rewardAccount}.`); - } - return [address.address, weight]; - }) - ); + // eslint-disable-next-line sonarjs/cognitive-complexity + #addScriptInput(input: Cardano.TxIn | Cardano.Utxo, scriptUnlockProps: ScriptUnlockProps) { + let txId: TxIdWithIndex; - return new GreedyInputSelector({ - getChangeAddresses: () => Promise.resolve(addressesAndWeights) - }); + if (Array.isArray(input)) { + txId = `${input[0].txId}#${input[0].index}`; + this.#preSelectedInputs.set(txId, input); + + if (input[1].datum) { + const hash = Serialization.PlutusData.fromCore(input[1].datum).hash(); + this.#knownDatums.set(hash, input[1].datum); + this.#knownInlineDatums.add(hash); + } + + if (input[1].datumHash) { + this.#unresolvedDatums.push(input[1].datumHash); + } + } else { + txId = `${input.txId}#${input.index}`; + + if ( + !this.#unresolvedInputs.some( + (unresolvedInput) => unresolvedInput.txId === input.txId && unresolvedInput.index === input.index + ) + ) + this.#unresolvedInputs.push(input); + } + + if (scriptUnlockProps.script) { + const hash = Serialization.Script.fromCore(scriptUnlockProps.script).hash(); + this.#knownScripts.set(hash, scriptUnlockProps.script); + } + + if (scriptUnlockProps.redeemer) { + this.#knownRedeemers.spend?.set(txId, { + data: scriptUnlockProps.redeemer, + executionUnits: { + memory: 0, + steps: 0 + }, + index: 0, + purpose: Cardano.RedeemerPurpose.spend + }); + } + + if (scriptUnlockProps.datum) { + if (Serialization.isDatumHash(scriptUnlockProps.datum)) { + if (!this.#knownDatums.has(scriptUnlockProps.datum)) this.#unresolvedDatums.push(scriptUnlockProps.datum); + } else { + const hash = Serialization.PlutusData.fromCore(scriptUnlockProps.datum).hash(); + this.#knownDatums.set(hash, scriptUnlockProps.datum); + } + } } } diff --git a/packages/tx-construction/src/tx-builder/costModels.ts b/packages/tx-construction/src/tx-builder/costModels.ts new file mode 100644 index 00000000000..c77d06a901f --- /dev/null +++ b/packages/tx-construction/src/tx-builder/costModels.ts @@ -0,0 +1,88 @@ +import { Cardano, Serialization } from '@cardano-sdk/core'; + +export type TaggedCostModel = { version: Cardano.PlutusLanguageVersion; prices: number[] }; + +// LW-7691: Currently cost models come from the backend out of order and as an object rather than an array. +// for now, we will hard code them. We can do this since the cost models are the same for all public networks. +// However, replacing this hardcoded models for the ones in the protocol parameters should be a priority +// once LW-7691 is resolved. + +export const plutusV1CostModel: TaggedCostModel = { + prices: [ + 205_665, 812, 1, 1, 1000, 571, 0, 1, 1000, 24_177, 4, 1, 1000, 32, 117_366, 10_475, 4, 23_000, 100, 23_000, 100, + 23_000, 100, 23_000, 100, 23_000, 100, 23_000, 100, 100, 100, 23_000, 100, 19_537, 32, 175_354, 32, 46_417, 4, + 221_973, 511, 0, 1, 89_141, 32, 497_525, 14_068, 4, 2, 196_500, 453_240, 220, 0, 1, 1, 1000, 28_662, 4, 2, 245_000, + 216_773, 62, 1, 1_060_367, 12_586, 1, 208_512, 421, 1, 187_000, 1000, 52_998, 1, 80_436, 32, 43_249, 32, 1000, 32, + 80_556, 1, 57_667, 4, 1000, 10, 197_145, 156, 1, 197_145, 156, 1, 204_924, 473, 1, 208_896, 511, 1, 52_467, 32, + 64_832, 32, 65_493, 32, 22_558, 32, 16_563, 32, 76_511, 32, 196_500, 453_240, 220, 0, 1, 1, 69_522, 11_687, 0, 1, + 60_091, 32, 196_500, 453_240, 220, 0, 1, 1, 196_500, 453_240, 220, 0, 1, 1, 806_990, 30_482, 4, 1_927_926, 82_523, + 4, 265_318, 0, 4, 0, 85_931, 32, 205_665, 812, 1, 1, 41_182, 32, 212_342, 32, 31_220, 32, 32_696, 32, 43_357, 32, + 32_247, 32, 38_314, 32, 57_996_947, 18_975, 10 + ], + version: Cardano.PlutusLanguageVersion.V1 +}; + +export const plutusV2CostModel: TaggedCostModel = { + prices: [ + 205_665, 812, 1, 1, 1000, 571, 0, 1, 1000, 24_177, 4, 1, 1000, 32, 117_366, 10_475, 4, 23_000, 100, 23_000, 100, + 23_000, 100, 23_000, 100, 23_000, 100, 23_000, 100, 100, 100, 23_000, 100, 19_537, 32, 175_354, 32, 46_417, 4, + 221_973, 511, 0, 1, 89_141, 32, 497_525, 14_068, 4, 2, 196_500, 453_240, 220, 0, 1, 1, 1000, 28_662, 4, 2, 245_000, + 216_773, 62, 1, 1_060_367, 12_586, 1, 208_512, 421, 1, 187_000, 1000, 52_998, 1, 80_436, 32, 43_249, 32, 1000, 32, + 80_556, 1, 57_667, 4, 1000, 10, 197_145, 156, 1, 197_145, 156, 1, 204_924, 473, 1, 208_896, 511, 1, 52_467, 32, + 64_832, 32, 65_493, 32, 22_558, 32, 16_563, 32, 76_511, 32, 196_500, 453_240, 220, 0, 1, 1, 69_522, 11_687, 0, 1, + 60_091, 32, 196_500, 453_240, 220, 0, 1, 1, 196_500, 453_240, 220, 0, 1, 1, 1_159_724, 392_670, 0, 2, 806_990, + 30_482, 4, 1_927_926, 82_523, 4, 265_318, 0, 4, 0, 85_931, 32, 205_665, 812, 1, 1, 41_182, 32, 212_342, 32, 31_220, + 32, 32_696, 32, 43_357, 32, 32_247, 32, 38_314, 32, 35_892_428, 10, 57_996_947, 18_975, 10, 38_887_044, 32_947, 10 + ], + version: Cardano.PlutusLanguageVersion.V2 +}; + +export const plutusV3CostModel: TaggedCostModel = { + prices: [ + 205_665, 812, 1, 1, 1000, 571, 0, 1, 1000, 24_177, 4, 1, 1000, 32, 117_366, 10_475, 4, 23_000, 100, 23_000, 100, + 23_000, 100, 23_000, 100, 23_000, 100, 23_000, 100, 100, 100, 23_000, 100, 19_537, 32, 175_354, 32, 46_417, 4, + 221_973, 511, 0, 1, 89_141, 32, 497_525, 14_068, 4, 2, 196_500, 453_240, 220, 0, 1, 1, 1000, 28_662, 4, 2, 245_000, + 216_773, 62, 1, 1_060_367, 12_586, 1, 208_512, 421, 1, 187_000, 1000, 52_998, 1, 80_436, 32, 43_249, 32, 1000, 32, + 80_556, 1, 57_667, 4, 1000, 10, 197_145, 156, 1, 197_145, 156, 1, 204_924, 473, 1, 208_896, 511, 1, 52_467, 32, + 64_832, 32, 65_493, 32, 22_558, 32, 16_563, 32, 76_511, 32, 196_500, 453_240, 220, 0, 1, 1, 69_522, 11_687, 0, 1, + 60_091, 32, 196_500, 453_240, 220, 0, 1, 1, 196_500, 453_240, 220, 0, 1, 1, 1_159_724, 392_670, 0, 2, 806_990, + 30_482, 4, 1_927_926, 82_523, 4, 265_318, 0, 4, 0, 85_931, 32, 205_665, 812, 1, 1, 41_182, 32, 212_342, 32, 31_220, + 32, 32_696, 32, 43_357, 32, 32_247, 32, 38_314, 32, 35_190_005, 10, 57_996_947, 18_975, 10, 39_121_781, 32_260, 10, + 100, 23_000, 100, 832_808, 18, 3_209_094, 6, 331_451, 1, 65_990_684, 23_097, 18, 114_242, 18, 94_393_407, 87_060, + 18, 16_420_089, 18, 2_145_798, 36, 3_795_345, 12, 889_023, 1, 204_237_282, 23_271, 36, 129_165, 36, 189_977_790, + 85_902, 36, 33_012_864, 36, 388_443_360, 1, 401_885_761, 72, 2_331_379, 72, 1_927_926, 82_523, 4, 117_366, 10_475, + 4, 1_292_075, 24_469, 74, 0, 1, 936_157, 49_601, 237, 0, 1 + ], + version: Cardano.PlutusLanguageVersion.V3 +}; + +export const buildCostModels = (models: TaggedCostModel[]): Cardano.CostModels => { + const costModels = new Serialization.Costmdls(); + + for (const model of models) { + const costModel = new Serialization.CostModel(model.version, model.prices); + + costModels.insert(costModel); + } + + return costModels.toCore(); +}; + +export const getDefaultCostModelsForVersions = (versions: Array) => { + const uniqueVersions = [...new Set(versions)]; + + const models = uniqueVersions.map((version) => { + switch (version) { + case Cardano.PlutusLanguageVersion.V1: + return plutusV1CostModel; + case Cardano.PlutusLanguageVersion.V2: + return plutusV2CostModel; + case Cardano.PlutusLanguageVersion.V3: + return plutusV3CostModel; + default: + throw new Error('Invalid plutus language'); + } + }); + + return buildCostModels(models); +}; diff --git a/packages/tx-construction/src/tx-builder/index.ts b/packages/tx-construction/src/tx-builder/index.ts index b2fe61398cb..c52e3d71762 100644 --- a/packages/tx-construction/src/tx-builder/index.ts +++ b/packages/tx-construction/src/tx-builder/index.ts @@ -2,3 +2,4 @@ export * from './OutputBuilder'; export * from './initializeTx'; export * from './types'; export * from './TxBuilder'; +export * from './GreedyTxEvaluator'; diff --git a/packages/tx-construction/src/tx-builder/initializeTx.ts b/packages/tx-construction/src/tx-builder/initializeTx.ts index 0c7487cf43f..077f1d223a9 100644 --- a/packages/tx-construction/src/tx-builder/initializeTx.ts +++ b/packages/tx-construction/src/tx-builder/initializeTx.ts @@ -1,10 +1,11 @@ import { StaticChangeAddressResolver, roundRobinRandomImprove } from '@cardano-sdk/input-selection'; import { Cardano, Serialization } from '@cardano-sdk/core'; +import { GreedyTxEvaluator } from './GreedyTxEvaluator'; import { InitializeTxProps, InitializeTxResult } from '../types'; +import { RedeemersByType, defaultSelectionConstraints } from '../input-selection'; import { TxBuilderDependencies } from './types'; import { createPreInputSelectionTxBody, includeChangeAndInputs } from '../createTransactionInternals'; -import { defaultSelectionConstraints } from '../input-selection'; import { ensureValidityInterval } from '../ensureValidityInterval'; import { util } from '@cardano-sdk/key-management'; @@ -28,6 +29,8 @@ export const initializeTx = async ( txBuilderProviders.addresses.get() ]); + const txEvaluator = props.txEvaluator ?? new GreedyTxEvaluator(() => txBuilderProviders.protocolParameters()); + inputSelector = inputSelector ?? roundRobinRandomImprove({ @@ -38,9 +41,11 @@ export const initializeTx = async ( const { txBody, auxiliaryData } = createPreInputSelectionTxBody({ auxiliaryData: props.auxiliaryData, certificates: props.certificates, + collateralReturn: props.collateralReturn, collaterals: props.collaterals, mint: props.mint, outputs: [...(props.outputs || [])], + referenceInputs: props.referenceInputs, requiredExtraSignatures: props.requiredExtraSignatures, scriptIntegrityHash: props.scriptIntegrityHash, validityInterval: ensureValidityInterval(tip.slot, genesisParameters, props.options?.validityInterval), @@ -62,7 +67,9 @@ export const initializeTx = async ( } const unwitnessedTx = includeChangeAndInputs({ bodyPreInputSelection, - inputSelection + inputSelection, + scriptVersions: props.scriptVersions, + witness: props.witness as Cardano.Witness }); const dRepPublicKey = addressManager @@ -90,7 +97,9 @@ export const initializeTx = async ( return tx; }, - protocolParameters + protocolParameters, + redeemersByType: props.redeemersByType ?? ({} as RedeemersByType), + txEvaluator }); const implicitCoin = Cardano.util.computeImplicitCoin(protocolParameters, { @@ -99,16 +108,22 @@ export const initializeTx = async ( withdrawals: bodyPreInputSelection.withdrawals }); - const { selection: inputSelection } = await inputSelector.select({ + const { selection: inputSelection, redeemers } = await inputSelector.select({ constraints, implicitValue: { coin: implicitCoin, mint: bodyPreInputSelection.mint }, outputs: new Set(bodyPreInputSelection.outputs), + preSelectedUtxo: props.inputs || new Set(), utxo: new Set(utxo) }); + const witness = { ...props.witness, redeemers } as Cardano.Witness; + const { body, hash } = includeChangeAndInputs({ bodyPreInputSelection, - inputSelection + inputSelection, + scriptVersions: props.scriptVersions, + witness }); - return { body, hash, inputSelection }; + + return { body, hash, inputSelection, redeemers }; }; diff --git a/packages/tx-construction/src/tx-builder/types.ts b/packages/tx-construction/src/tx-builder/types.ts index 49a13cf873f..a2a79c660fa 100644 --- a/packages/tx-construction/src/tx-builder/types.ts +++ b/packages/tx-construction/src/tx-builder/types.ts @@ -162,6 +162,7 @@ export type TxInspection = Cardano.TxBodyWithHash & { auxiliaryData?: Cardano.AuxiliaryData; inputSelection: SelectionSkeleton; ownAddresses: GroupedAddress[]; + witness?: Cardano.Witness; }; /** @@ -191,19 +192,102 @@ export interface PartialTx { type CustomizeCbProps = { txBody: Readonly }; export type CustomizeCb = (props: CustomizeCbProps) => TxBodyPreInputSelection; +/** + * Type alias for a function that resolves a datum hash to the corresponding Plutus data. + * + * @param {Cardano.DatumHash} datumHash - The hash of the datum to be resolved. + * @returns {Promise} A promise that resolves to the Plutus data or null if not found. + */ +export type ResolveDatum = (datumHash: Cardano.DatumHash) => Promise; + +/** + * Interface for a datum resolver. + * + * @property {ResolveDatum} resolve - A function that resolves a datum hash to the corresponding Plutus data. + */ +export interface DatumResolver { + resolve: ResolveDatum; +} + +/** + * Type alias for the result of a transaction evaluation. + * It is an array of objects that contain the purpose, index, and budget for each redeemer. + * + * @param purpose - The purpose of the redeemer. + * @param index - The index of the redeemer. + * @param budget - The execution budget for the redeemer. + */ +export type TxEvaluationResult = Array<{ + purpose: Cardano.RedeemerPurpose; + index: number; + budget: Cardano.ExUnits; +}>; + +/** + * Evaluates a transaction and provides an evaluation result. + * + * @param tx - The transaction to be evaluated. + * @param resolvedInputs - The list of resolved UTXOs used in the transaction. + * @returns A promise that resolves to the transaction evaluation result. + */ +export interface TxEvaluator { + evaluate(tx: Cardano.Tx, resolvedInputs: Array): Promise; +} + +/** + * Type alias for the properties required to unlock a script. + * + * @param redeemer - The Plutus data for the redeemer (optional). + * @param script - The script used for unlocking (optional). + * @param datum - The datum associated with the output, required only when the output was created with a TxHash and no datum resolver was provided (optional). + */ +export type ScriptUnlockProps = { + redeemer?: Cardano.PlutusData; + script?: Cardano.Script; + // Only required when the output was created with a TxHash and no datum resolver was provided + datum?: Cardano.PlutusData; +}; + export interface TxBuilder { /** * @returns a partial transaction that has properties set by calling other TxBuilder methods. Does not validate the transaction. */ inspect(): Promise; + /** + * Adds a reference input to the transaction. + * + * @param input - The transaction input or UTXO to add. + * @returns {TxBuilder} The current TxBuilder instance for chaining. + */ + addReferenceInput(input: Cardano.TxIn | Cardano.Utxo): TxBuilder; + + /** + * Adds an input to the transaction with optional script unlock properties. + * + * @param input - The transaction input or UTXO to add. + * @param scriptUnlockProps - Optional properties for unlocking the script. + * @returns {TxBuilder} The current TxBuilder instance for chaining. + */ + addInput(input: Cardano.TxIn | Cardano.Utxo, scriptUnlockProps?: ScriptUnlockProps): TxBuilder; + + /** + * Adds a datum to the transaction. + * + * @param datum - The Plutus datum to add. + * @returns {TxBuilder} The current TxBuilder instance for chaining. + */ + addDatum(datum: Cardano.PlutusData): TxBuilder; + /** @param txOut transaction output to add to {@link partialTxBody} outputs. */ addOutput(txOut: Cardano.TxOut): TxBuilder; + /** * @param txOut transaction output to be removed from {@link partialTxBody} outputs. * It must be in partialTxBody.outputs (===) */ removeOutput(txOut: Cardano.TxOut): TxBuilder; + /** * Does *not* addOutput. * @@ -289,16 +373,13 @@ export interface TxBuilder { // - setMint // - setMetadatum(label: bigint, metadatum: Cardano.Metadatum | null); // - burn - // TODO: maybe this, or maybe datum should be added together with an output? - // collaterals should be automatically computed and added to tx when you add scripts - // - setScripts(scripts: Array<{script, datum, redeemer}>) - // TODO: figure out what script_data_hash is used for - // - setScriptIntegrityHash(hash: Cardano.util.Hash32ByteBase16 | null); // - setRequiredExtraSignatures(keyHashes: Cardano.Ed25519KeyHash[]); } export interface TxBuilderDependencies { inputSelector?: InputSelector; + datumResolver?: DatumResolver; + txEvaluator: TxEvaluator; inputResolver: Cardano.InputResolver; bip32Account?: Bip32Account; witnesser: Witnesser; diff --git a/packages/tx-construction/src/tx-builder/utils.ts b/packages/tx-construction/src/tx-builder/utils.ts new file mode 100644 index 00000000000..b526bbd6af5 --- /dev/null +++ b/packages/tx-construction/src/tx-builder/utils.ts @@ -0,0 +1,215 @@ +import * as Crypto from '@cardano-sdk/crypto'; +import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core'; + +import { + ComputeMinimumCoinQuantity, + GreedyInputSelector, + TokenBundleSizeExceedsLimit +} from '@cardano-sdk/input-selection'; +import { GroupedAddress } from '@cardano-sdk/key-management'; +import { InvalidHereafterError } from './types'; +import { RedeemersByType, computeMinimumCoinQuantity, tokenBundleSizeExceedsLimit } from '../input-selection'; +import { RewardAccountWithPoolId, TxBuilderProviders } from '../types'; +import { ValidityInterval } from '@cardano-sdk/core/dist/cjs/Cardano'; +import minBy from 'lodash/minBy'; + +const COLLATERAL_AMOUNT_IN_LOVELACE = 5_000_000n; + +/** + * Sorts the given Utxo by coin size in descending order. + * + * @param lhs The left-hand side of the comparison operation. + * @param rhs The left-hand side of the comparison operation. + */ +const sortByCoins = (lhs: Cardano.Utxo, rhs: Cardano.Utxo) => { + if (lhs[1].value.coins > rhs[1].value.coins) { + return -1; + } else if (lhs[1].value.coins < rhs[1].value.coins) { + return 1; + } + return 0; +}; + +/** + * Gets whether the given value will produce a valid UTXO. + * + * @param out The value to be tested. + * @param computeCoinQuantity callback that computes the minimum coin quantity for the given UTXO. + * @param computeBundleSizeExceedsLimit callback that determines if a token bundle has exceeded its size limit. + * @returns true if the value is valid; otherwise, false. + */ +const isValidValue = ( + out: Cardano.TxOut, + computeCoinQuantity: ComputeMinimumCoinQuantity, + computeBundleSizeExceedsLimit: TokenBundleSizeExceedsLimit +): boolean => { + let isValid = out.value.coins >= computeCoinQuantity(out); + + if (out.value.assets) isValid = isValid && !computeBundleSizeExceedsLimit(out.value.assets); + + return isValid; +}; + +export type RewardAccountsAndWeights = Map; + +/** + * Searches the payment address with the smallest index associated to the reward accounts. + * + * @param rewardAccountsWithWeights reward account addresses and the portfolio distribution weights. + * @param ownAddresses addresses to search in by reward account. + * @returns GreedyInputSelector with the addresses and weights to use as change addresses. + * @throws in case some reward accounts are not associated with any of the own addresses + */ +export const createGreedyInputSelector = ( + rewardAccountsWithWeights: RewardAccountsAndWeights, + ownAddresses: GroupedAddress[] +) => { + // select the address with smallest index for each reward account + const addressesAndWeights = new Map( + [...rewardAccountsWithWeights].map(([rewardAccount, weight]) => { + const address = minBy( + ownAddresses.filter((ownAddr) => ownAddr.rewardAccount === rewardAccount), + ({ index }) => index + ); + if (!address) { + throw new Error(`Could not find any address associated with ${rewardAccount}.`); + } + return [address.address, weight]; + }) + ); + + return new GreedyInputSelector({ + getChangeAddresses: () => Promise.resolve(addressesAndWeights) + }); +}; + +/** Registered and delegated < Registered < Unregistered */ +export const sortRewardAccountsDelegatedFirst = (a: RewardAccountWithPoolId, b: RewardAccountWithPoolId): number => { + const getScore = (acct: RewardAccountWithPoolId) => { + let score = 2; + if (acct.credentialStatus === Cardano.StakeCredentialStatus.Registered) { + score = 1; + if (acct.delegatee?.nextNextEpoch) { + score = 0; + } + } + return score; + }; + + return getScore(a) - getScore(b); +}; + +export const buildRedeemers = (redeemersData: RedeemersByType, maxExecutionUnits: Cardano.ExUnits) => { + const redeemers = []; + + const knownRedeemers = [ + ...(redeemersData.mint ?? []), + ...(redeemersData.vote ?? []), + ...(redeemersData.propose ?? []), + ...(redeemersData.certificate ?? []), + ...(redeemersData.withdrawal ?? []), + ...(redeemersData.spend ? [...redeemersData.spend.values()] : []) + ]; + + for (const value of knownRedeemers) { + const index = Number.MAX_SAFE_INTEGER; + + redeemers.push({ + data: value.data, + executionUnits: + value.executionUnits.memory === 0 && value.executionUnits.steps === 0 + ? maxExecutionUnits + : value.executionUnits, + index, + purpose: value.purpose + }); + } + + return redeemers; +}; + +export const validateValidityInterval = (tip: Cardano.Tip, validityInterval?: ValidityInterval) => { + if (!validityInterval?.invalidHereafter) { + return; + } + + if (tip.slot >= validityInterval.invalidHereafter) { + throw new InvalidHereafterError(); + } +}; + +export const buildWitness = async ( + knownScripts: Map, + knownReferenceScripts: Set, + knownDatums: Map, + knownInlineDatums: Set, + knownRedeemers: RedeemersByType, + providers: TxBuilderProviders + // eslint-disable-next-line max-params +): Promise => { + const witnesses = { signatures: new Map() } as Cardano.Witness; + + if (knownDatums) { + witnesses.datums = []; + + for (const [key, value] of knownDatums) { + if (!knownInlineDatums.has(key)) witnesses.datums.push(value); + } + } + + if (knownScripts) { + witnesses.scripts = []; + + for (const [key, value] of knownScripts) { + if (!knownReferenceScripts.has(key)) witnesses.scripts.push(value); + } + } + + const { maxExecutionUnitsPerTransaction } = await providers.protocolParameters(); + + if (knownRedeemers) { + witnesses.redeemers = buildRedeemers(knownRedeemers, maxExecutionUnitsPerTransaction); + } + + return witnesses; +}; + +export const computeCollateral = async ( + providers: TxBuilderProviders +): Promise<{ collaterals: Set; collateralReturn: Cardano.TxOut }> => { + const availableUtxo = (await providers.utxoAvailable()).sort(sortByCoins); + + const selectedCollateral = []; + + const { coinsPerUtxoByte, maxValueSize } = await providers.protocolParameters(); + + let totalCoins = 0n; + for (const utxo of availableUtxo) { + selectedCollateral.push(utxo); + totalCoins += utxo[1].value.coins; + + if (totalCoins > COLLATERAL_AMOUNT_IN_LOVELACE) { + const returnAddress = selectedCollateral[0][1].address; + + const collateralValues = selectedCollateral.map((x) => x[1].value); + const totalValueInCollateral = coalesceValueQuantities(collateralValues); + const collateralReturnValue = { ...totalValueInCollateral }; + + collateralReturnValue.coins -= COLLATERAL_AMOUNT_IN_LOVELACE; + + const collateralReturn = { address: returnAddress, value: collateralReturnValue }; + + if ( + isValidValue( + collateralReturn, + computeMinimumCoinQuantity(coinsPerUtxoByte), + tokenBundleSizeExceedsLimit(maxValueSize) + ) + ) { + return { collateralReturn, collaterals: new Set(selectedCollateral.map((x) => x[0])) }; + } + } + } + + throw new Error('No suitable collateral found'); +}; diff --git a/packages/tx-construction/src/types.ts b/packages/tx-construction/src/types.ts index 02f518c7f4c..8ee21e5b447 100644 --- a/packages/tx-construction/src/types.ts +++ b/packages/tx-construction/src/types.ts @@ -3,10 +3,14 @@ import { Cardano, HandleResolution } from '@cardano-sdk/core'; import { GroupedAddress, SignTransactionOptions } from '@cardano-sdk/key-management'; import { SelectionSkeleton } from '@cardano-sdk/input-selection'; -import { CustomizeCb } from './tx-builder'; +import { CustomizeCb, TxEvaluator } from './tx-builder'; import { MinimumCoinQuantityPerOutput } from './output-validation'; +import { RedeemersByType } from './input-selection'; -export type InitializeTxResult = Cardano.TxBodyWithHash & { inputSelection: SelectionSkeleton }; +export type InitializeTxResult = Cardano.TxBodyWithHash & { + inputSelection: SelectionSkeleton; + redeemers?: Array; +}; export type RewardAccountWithPoolId = Omit & { delegatee?: { nextNextEpoch?: { id: Cardano.PoolId } }; @@ -35,12 +39,16 @@ export type InitializeTxWitness = Partial; export type TxBodyPreInputSelection = Omit; export interface InitializeTxProps { + // Inputs specified at this stage will be included in the transaction regardless of the input selection result. + inputs?: Set; + referenceInputs?: Set; outputs?: Set; certificates?: Cardano.Certificate[]; options?: { validityInterval?: Cardano.ValidityInterval; }; collaterals?: Set; + collateralReturn?: Cardano.TxOut; mint?: Cardano.TokenMap; scriptIntegrityHash?: Crypto.Hash32ByteBase16; requiredExtraSignatures?: Crypto.Ed25519KeyHashHex[]; @@ -51,6 +59,9 @@ export interface InitializeTxProps { proposalProcedures?: Cardano.ProposalProcedure[]; /** callback function that allows updating the transaction before input selection */ customizeCb?: CustomizeCb; + txEvaluator?: TxEvaluator; + redeemersByType?: RedeemersByType; + scriptVersions?: Set; } export interface InitializeTxPropsValidationResult { diff --git a/packages/tx-construction/test/computeScriptDataHash.test.ts b/packages/tx-construction/test/computeScriptDataHash.test.ts index bea61a0b4eb..fe027b5bf4b 100644 --- a/packages/tx-construction/test/computeScriptDataHash.test.ts +++ b/packages/tx-construction/test/computeScriptDataHash.test.ts @@ -1,9 +1,8 @@ import { Cardano, Serialization, TxCBOR } from '@cardano-sdk/core'; import { HexBlob } from '@cardano-sdk/util'; +import { TaggedCostModel, buildCostModels, plutusV1CostModel, plutusV2CostModel } from '../src/tx-builder/costModels'; import { computeScriptDataHash } from '../src'; -type TaggedCostModel = { version: Cardano.PlutusLanguageVersion; prices: number[] }; - const alonzoPlutusV1CostModel: TaggedCostModel = { prices: [ 197_209, 0, 1, 1, 396_231, 621, 0, 1, 150_000, 1000, 0, 1, 150_000, 32, 2_477_736, 29_175, 4, 29_773, 100, 29_773, @@ -34,50 +33,8 @@ const localNetworkPlutusV2CostModel: TaggedCostModel = { version: Cardano.PlutusLanguageVersion.V2 }; -const vasilPlutusV1CostModel: TaggedCostModel = { - prices: [ - 205_665, 812, 1, 1, 1000, 571, 0, 1, 1000, 24_177, 4, 1, 1000, 32, 117_366, 10_475, 4, 23_000, 100, 23_000, 100, - 23_000, 100, 23_000, 100, 23_000, 100, 23_000, 100, 100, 100, 23_000, 100, 19_537, 32, 175_354, 32, 46_417, 4, - 221_973, 511, 0, 1, 89_141, 32, 497_525, 14_068, 4, 2, 196_500, 453_240, 220, 0, 1, 1, 1000, 28_662, 4, 2, 245_000, - 216_773, 62, 1, 1_060_367, 12_586, 1, 208_512, 421, 1, 187_000, 1000, 52_998, 1, 80_436, 32, 43_249, 32, 1000, 32, - 80_556, 1, 57_667, 4, 1000, 10, 197_145, 156, 1, 197_145, 156, 1, 204_924, 473, 1, 208_896, 511, 1, 52_467, 32, - 64_832, 32, 65_493, 32, 22_558, 32, 16_563, 32, 76_511, 32, 196_500, 453_240, 220, 0, 1, 1, 69_522, 11_687, 0, 1, - 60_091, 32, 196_500, 453_240, 220, 0, 1, 1, 196_500, 453_240, 220, 0, 1, 1, 806_990, 30_482, 4, 1_927_926, 82_523, - 4, 265_318, 0, 4, 0, 85_931, 32, 205_665, 812, 1, 1, 41_182, 32, 212_342, 32, 31_220, 32, 32_696, 32, 43_357, 32, - 32_247, 32, 38_314, 32, 57_996_947, 18_975, 10 - ], - version: Cardano.PlutusLanguageVersion.V1 -}; - -const vasilPlutusV2CostModel: TaggedCostModel = { - prices: [ - 205_665, 812, 1, 1, 1000, 571, 0, 1, 1000, 24_177, 4, 1, 1000, 32, 117_366, 10_475, 4, 23_000, 100, 23_000, 100, - 23_000, 100, 23_000, 100, 23_000, 100, 23_000, 100, 100, 100, 23_000, 100, 19_537, 32, 175_354, 32, 46_417, 4, - 221_973, 511, 0, 1, 89_141, 32, 497_525, 14_068, 4, 2, 196_500, 453_240, 220, 0, 1, 1, 1000, 28_662, 4, 2, 245_000, - 216_773, 62, 1, 1_060_367, 12_586, 1, 208_512, 421, 1, 187_000, 1000, 52_998, 1, 80_436, 32, 43_249, 32, 1000, 32, - 80_556, 1, 57_667, 4, 1000, 10, 197_145, 156, 1, 197_145, 156, 1, 204_924, 473, 1, 208_896, 511, 1, 52_467, 32, - 64_832, 32, 65_493, 32, 22_558, 32, 16_563, 32, 76_511, 32, 196_500, 453_240, 220, 0, 1, 1, 69_522, 11_687, 0, 1, - 60_091, 32, 196_500, 453_240, 220, 0, 1, 1, 196_500, 453_240, 220, 0, 1, 1, 1_159_724, 392_670, 0, 2, 806_990, - 30_482, 4, 1_927_926, 82_523, 4, 265_318, 0, 4, 0, 85_931, 32, 205_665, 812, 1, 1, 41_182, 32, 212_342, 32, 31_220, - 32, 32_696, 32, 43_357, 32, 32_247, 32, 38_314, 32, 35_892_428, 10, 57_996_947, 18_975, 10, 38_887_044, 32_947, 10 - ], - version: Cardano.PlutusLanguageVersion.V2 -}; - -const buildCostModels = (models: TaggedCostModel[]): Cardano.CostModels => { - const costModels = new Serialization.Costmdls(); - - for (const model of models) { - const alonzoCostModel = new Serialization.CostModel(model.version, model.prices); - - costModels.insert(alonzoCostModel); - } - - return costModels.toCore(); -}; - describe('computeScriptDataHash', () => { - const costModels = buildCostModels([vasilPlutusV1CostModel, vasilPlutusV2CostModel]); + const costModels = buildCostModels([plutusV1CostModel, plutusV2CostModel]); it('can compute script data hash for plutus v1 scripts using alonzo era cost models', () => { // Arrange diff --git a/packages/tx-construction/test/createTransactionInternals.test.ts b/packages/tx-construction/test/createTransactionInternals.test.ts index d876ce67eb6..4656cc74e3f 100644 --- a/packages/tx-construction/test/createTransactionInternals.test.ts +++ b/packages/tx-construction/test/createTransactionInternals.test.ts @@ -34,6 +34,7 @@ describe('createTransactionInternals', () => { }).select({ constraints: SelectionConstraints.NO_CONSTRAINTS, outputs: new Set(outputs), + preSelectedUtxo: new Set(), utxo: new Set(utxo) }); const ledgerTip = await provider.ledgerTip(); diff --git a/packages/tx-construction/test/input-selection/selectionConstraints.test.ts b/packages/tx-construction/test/input-selection/selectionConstraints.test.ts index c8e5bec97ee..e08007cef4b 100644 --- a/packages/tx-construction/test/input-selection/selectionConstraints.test.ts +++ b/packages/tx-construction/test/input-selection/selectionConstraints.test.ts @@ -3,10 +3,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable unicorn/consistent-function-scoping */ import { AssetId } from '@cardano-sdk/util-dev'; -import { Cardano, InvalidProtocolParametersError } from '@cardano-sdk/core'; +import { Cardano, InvalidProtocolParametersError, Serialization } from '@cardano-sdk/core'; import { DefaultSelectionConstraintsProps, defaultSelectionConstraints } from '../../src'; +import { HexBlob } from '@cardano-sdk/util'; import { ProtocolParametersForInputSelection, SelectionSkeleton } from '@cardano-sdk/input-selection'; import { babbageTx, getBigBabbageTx } from '../testData'; +import { mockTxEvaluator } from '../tx-builder/mocks'; describe('defaultSelectionConstraints', () => { const protocolParameters = { @@ -29,15 +31,63 @@ describe('defaultSelectionConstraints', () => { }); it('computeMinimumCost', async () => { - const fee = 218_763n; + const fee = 218_137n; const buildTx = jest.fn(async () => babbageTx); - const selectionSkeleton = {} as SelectionSkeleton; + const selectionSkeleton = { inputs: [] } as unknown as SelectionSkeleton; const constraints = defaultSelectionConstraints({ buildTx, - protocolParameters + protocolParameters, + redeemersByType: { + certificate: [ + { + data: Serialization.PlutusData.fromCbor(HexBlob('d86682008102')).toCore(), + executionUnits: { + memory: 0, + steps: 0 + }, + index: 1, + purpose: Cardano.RedeemerPurpose.certificate + } + ], + mint: [ + { + data: Serialization.PlutusData.fromCbor(HexBlob('d86682008101')).toCore(), + executionUnits: { + memory: 0, + steps: 0 + }, + index: 0, + purpose: Cardano.RedeemerPurpose.mint + } + ] + }, + txEvaluator: mockTxEvaluator }); + const result = await constraints.computeMinimumCost(selectionSkeleton); - expect(result).toEqual(fee); + expect(result).toEqual({ + fee, + redeemers: [ + { + data: Serialization.PlutusData.fromCbor(HexBlob('d86682008101')).toCore(), + executionUnits: { + memory: 100, + steps: 200 + }, + index: 0, + purpose: Cardano.RedeemerPurpose.mint + }, + { + data: Serialization.PlutusData.fromCbor(HexBlob('d86682008102')).toCore(), + executionUnits: { + memory: 100, + steps: 200 + }, + index: 1, + purpose: Cardano.RedeemerPurpose.certificate + } + ] + }); expect(buildTx).toBeCalledTimes(1); expect(buildTx).toBeCalledWith(selectionSkeleton); }); @@ -64,7 +114,9 @@ describe('defaultSelectionConstraints', () => { it("doesn't exceed max tx size", async () => { const constraints = defaultSelectionConstraints({ buildTx: async () => babbageTx, - protocolParameters + protocolParameters, + redeemersByType: {}, + txEvaluator: mockTxEvaluator }); expect(await constraints.computeSelectionLimit({ inputs: new Set([1, 2]) as any } as SelectionSkeleton)).toEqual( 2 @@ -74,7 +126,9 @@ describe('defaultSelectionConstraints', () => { it('exceeds max tx size', async () => { const constraints = defaultSelectionConstraints({ buildTx: getBigBabbageTx, - protocolParameters + protocolParameters, + redeemersByType: {}, + txEvaluator: mockTxEvaluator }); expect(await constraints.computeSelectionLimit({ inputs: new Set([1, 2]) as any } as SelectionSkeleton)).toEqual( 1 @@ -86,7 +140,9 @@ describe('defaultSelectionConstraints', () => { it('empty bundle', () => { const constraints = defaultSelectionConstraints({ buildTx: jest.fn(), - protocolParameters + protocolParameters, + redeemersByType: {}, + txEvaluator: mockTxEvaluator }); expect(constraints.tokenBundleSizeExceedsLimit()).toBe(false); }); diff --git a/packages/tx-construction/test/tx-builder/GreedyTxEvaluator.test.ts b/packages/tx-construction/test/tx-builder/GreedyTxEvaluator.test.ts new file mode 100644 index 00000000000..dee7cb35bfe --- /dev/null +++ b/packages/tx-construction/test/tx-builder/GreedyTxEvaluator.test.ts @@ -0,0 +1,48 @@ +import { Cardano } from '@cardano-sdk/core'; +import { GreedyTxEvaluator } from '../../src'; + +const getParams = (): Promise => + Promise.resolve({ + maxExecutionUnitsPerTransaction: { + memory: 100, + steps: 200 + } + } as unknown as Cardano.ProtocolParameters); + +const tx = { + witness: { + redeemers: [ + { + data: 1n, + executionUnits: { + memory: 0, + steps: 0 + }, + index: 0, + purpose: Cardano.RedeemerPurpose.spend + } + ] + } +} as unknown as Cardano.Tx; + +describe('GreedyTxEvaluator', () => { + it('assigns maxExecutionUnitsPerTransaction to the redeemer', async () => { + // Arrange + const evaluator = new GreedyTxEvaluator(getParams); + + // Act + const result = await evaluator.evaluate(tx, []); + + // Assert + expect(result).toEqual([ + { + budget: { + memory: 100, + steps: 200 + }, + index: 0, + purpose: Cardano.RedeemerPurpose.spend + } + ]); + }); +}); diff --git a/packages/tx-construction/test/tx-builder/TxBuilder.bootstrap.test.ts b/packages/tx-construction/test/tx-builder/TxBuilder.bootstrap.test.ts index a330f06a738..ef102c1569c 100644 --- a/packages/tx-construction/test/tx-builder/TxBuilder.bootstrap.test.ts +++ b/packages/tx-construction/test/tx-builder/TxBuilder.bootstrap.test.ts @@ -4,6 +4,7 @@ import { GenericTxBuilder, OutputValidation, TxBuilderProviders } from '../../sr import { SodiumBip32Ed25519 } from '@cardano-sdk/crypto'; import { dummyLogger } from 'ts-log'; import { logger, mockProviders as mocks } from '@cardano-sdk/util-dev'; +import { mockTxEvaluator } from './mocks'; describe.each([ ['TxBuilderGeneric', false], @@ -66,6 +67,7 @@ describe.each([ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(keyAgent) }; const txBuilder = new GenericTxBuilder(builderParams); diff --git a/packages/tx-construction/test/tx-builder/TxBuilder.test.ts b/packages/tx-construction/test/tx-builder/TxBuilder.test.ts index 83881cba62d..6b42ad3a369 100644 --- a/packages/tx-construction/test/tx-builder/TxBuilder.test.ts +++ b/packages/tx-construction/test/tx-builder/TxBuilder.test.ts @@ -12,7 +12,7 @@ import { util } from '@cardano-sdk/key-management'; import { AssetId, mockProviders as mocks } from '@cardano-sdk/util-dev'; -import { BigIntMath } from '@cardano-sdk/util'; +import { BigIntMath, HexBlob } from '@cardano-sdk/util'; import { Cardano, Handle, ProviderError, ProviderFailure } from '@cardano-sdk/core'; import { GenericTxBuilder, @@ -29,6 +29,7 @@ import { TxOutputFailure } from '../../src'; import { dummyLogger } from 'ts-log'; +import { mockTxEvaluator } from './mocks'; function assertObjectRefsAreDifferent(obj1: unknown, obj2: unknown): void { expect(obj1).not.toBe(obj2); @@ -115,6 +116,7 @@ describe.each([ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) }; @@ -367,16 +369,34 @@ describe.each([ expect(outputBuilder.toTxOut().address).toEqual(address); }); - it('can set datum', () => { + it('can set datum hash', () => { outputBuilder.datum(datumHash); expect(outputBuilder.toTxOut().datumHash).toEqual(datumHash); }); + it('can set inline datum', () => { + outputBuilder.datum(1n); + expect(outputBuilder.toTxOut().datum).toEqual(1n); + }); + it('can set handle', () => { outputBuilder.handle(handle); expect(outputBuilder.toTxOut().handle).toEqual(handle); }); + it('can set script reference', () => { + const alwaysSucceedsScript: Cardano.PlutusScript = { + __type: Cardano.ScriptType.Plutus, + bytes: HexBlob( + '59079201000033232323232323232323232323232332232323232323232222232325335333006300800530070043333573466E1CD55CEA80124000466442466002006004646464646464646464646464646666AE68CDC39AAB9D500C480008CCCCCCCCCCCC88888888888848CCCCCCCCCCCC00403403002C02802402001C01801401000C008CD4060064D5D0A80619A80C00C9ABA1500B33501801A35742A014666AA038EB9406CD5D0A804999AA80E3AE501B35742A01066A0300466AE85401CCCD54070091D69ABA150063232323333573466E1CD55CEA801240004664424660020060046464646666AE68CDC39AAB9D5002480008CC8848CC00400C008CD40B9D69ABA15002302F357426AE8940088C98C80C8CD5CE01981901809AAB9E5001137540026AE854008C8C8C8CCCD5CD19B8735573AA004900011991091980080180119A8173AD35742A004605E6AE84D5D1280111931901919AB9C033032030135573CA00226EA8004D5D09ABA2500223263202E33573805E05C05826AAE7940044DD50009ABA1500533501875C6AE854010CCD540700808004D5D0A801999AA80E3AE200135742A00460446AE84D5D1280111931901519AB9C02B02A028135744A00226AE8940044D5D1280089ABA25001135744A00226AE8940044D5D1280089ABA25001135744A00226AE8940044D55CF280089BAA00135742A00460246AE84D5D1280111931900E19AB9C01D01C01A101B13263201B3357389201035054350001B135573CA00226EA80054049404448C88C008DD6000990009AA80A911999AAB9F0012500A233500930043574200460066AE880080548C8C8CCCD5CD19B8735573AA004900011991091980080180118061ABA150023005357426AE8940088C98C8054CD5CE00B00A80989AAB9E5001137540024646464646666AE68CDC39AAB9D5004480008CCCC888848CCCC00401401000C008C8C8C8CCCD5CD19B8735573AA0049000119910919800801801180A9ABA1500233500F014357426AE8940088C98C8068CD5CE00D80D00C09AAB9E5001137540026AE854010CCD54021D728039ABA150033232323333573466E1D4005200423212223002004357426AAE79400C8CCCD5CD19B875002480088C84888C004010DD71ABA135573CA00846666AE68CDC3A801A400042444006464C6403866AE700740700680640604D55CEA80089BAA00135742A00466A016EB8D5D09ABA2500223263201633573802E02C02826AE8940044D5D1280089AAB9E500113754002266AA002EB9D6889119118011BAB00132001355012223233335573E0044A010466A00E66442466002006004600C6AAE754008C014D55CF280118021ABA200301313574200222440042442446600200800624464646666AE68CDC3A800A40004642446004006600A6AE84D55CF280191999AB9A3370EA0049001109100091931900899AB9C01201100F00E135573AA00226EA80048C8C8CCCD5CD19B875001480188C848888C010014C01CD5D09AAB9E500323333573466E1D400920042321222230020053009357426AAE7940108CCCD5CD19B875003480088C848888C004014C01CD5D09AAB9E500523333573466E1D40112000232122223003005375C6AE84D55CF280311931900899AB9C01201100F00E00D00C135573AA00226EA80048C8C8CCCD5CD19B8735573AA004900011991091980080180118029ABA15002375A6AE84D5D1280111931900699AB9C00E00D00B135573CA00226EA80048C8CCCD5CD19B8735573AA002900011BAE357426AAE7940088C98C802CCD5CE00600580489BAA001232323232323333573466E1D4005200C21222222200323333573466E1D4009200A21222222200423333573466E1D400D2008233221222222233001009008375C6AE854014DD69ABA135744A00A46666AE68CDC3A8022400C4664424444444660040120106EB8D5D0A8039BAE357426AE89401C8CCCD5CD19B875005480108CC8848888888CC018024020C030D5D0A8049BAE357426AE8940248CCCD5CD19B875006480088C848888888C01C020C034D5D09AAB9E500B23333573466E1D401D2000232122222223005008300E357426AAE7940308C98C8050CD5CE00A80A00900880800780700680609AAB9D5004135573CA00626AAE7940084D55CF280089BAA0012323232323333573466E1D400520022333222122333001005004003375A6AE854010DD69ABA15003375A6AE84D5D1280191999AB9A3370EA0049000119091180100198041ABA135573CA00C464C6401A66AE7003803402C0284D55CEA80189ABA25001135573CA00226EA80048C8C8CCCD5CD19B875001480088C8488C00400CDD71ABA135573CA00646666AE68CDC3A8012400046424460040066EB8D5D09AAB9E500423263200A33573801601401000E26AAE7540044DD500089119191999AB9A3370EA00290021091100091999AB9A3370EA00490011190911180180218031ABA135573CA00846666AE68CDC3A801A400042444004464C6401666AE7003002C02402001C4D55CEA80089BAA0012323333573466E1D40052002212200223333573466E1D40092000212200123263200733573801000E00A00826AAE74DD5000891999AB9A3370E6AAE74DD5000A40004008464C6400866AE700140100092612001490103505431001123230010012233003300200200122212200201' + ), + version: Cardano.PlutusLanguageVersion.V2 + }; + + outputBuilder.scriptReference(alwaysSucceedsScript); + expect(outputBuilder.toTxOut().scriptReference).toEqual(alwaysSucceedsScript); + }); + it('throws an error if attempting to set handle without a handleProvider', async () => { try { await txBuilderWithoutHandleProvider.buildOutput().handle(address).build(); diff --git a/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts b/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts index d69910aaa68..f4eef6e2260 100644 --- a/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts +++ b/packages/tx-construction/test/tx-builder/TxBuilderDelegatePortfolio.test.ts @@ -12,6 +12,7 @@ import { } from '../../src'; import { GreedyInputSelector, GreedySelectorProps, roundRobinRandomImprove } from '@cardano-sdk/input-selection'; import { dummyLogger } from 'ts-log'; +import { mockTxEvaluator } from './mocks'; import { mockProviders as mocks } from '@cardano-sdk/util-dev'; import uniqBy from 'lodash/uniqBy'; @@ -112,6 +113,7 @@ const createTxBuilder = async ({ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) }), txBuilderProviders, @@ -120,6 +122,7 @@ const createTxBuilder = async ({ logger: dummyLogger, outputValidator, txBuilderProviders, + txEvaluator: mockTxEvaluator, witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) }) }; diff --git a/packages/tx-construction/test/tx-builder/TxBuilderPlutusScripts.test.ts b/packages/tx-construction/test/tx-builder/TxBuilderPlutusScripts.test.ts new file mode 100644 index 00000000000..c76b3578e88 --- /dev/null +++ b/packages/tx-construction/test/tx-builder/TxBuilderPlutusScripts.test.ts @@ -0,0 +1,562 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import * as Crypto from '@cardano-sdk/crypto'; +import { AddressType, Bip32Account, InMemoryKeyAgent, util } from '@cardano-sdk/key-management'; +import { Cardano, coalesceValueQuantities } from '@cardano-sdk/core'; +import { + DatumResolver, + GenericTxBuilder, + OutputValidation, + ResolveDatum, + RewardAccountWithPoolId, + TxBuilderProviders +} from '../../src'; +import { HexBlob } from '@cardano-sdk/util'; +import { dummyLogger } from 'ts-log'; +import { mockTxEvaluator } from './mocks'; +import { mockProviders as mocks } from '@cardano-sdk/util-dev'; +import { roundRobinRandomImprove } from '@cardano-sdk/input-selection'; +import uniqBy from 'lodash/uniqBy'; + +jest.mock('@cardano-sdk/input-selection', () => { + const actual = jest.requireActual('@cardano-sdk/input-selection'); + return { + ...actual, + GreedyInputSelector: jest.fn((args) => new actual.GreedyInputSelector(args)), + roundRobinRandomImprove: jest.fn((args) => actual.roundRobinRandomImprove(args)) + }; +}); + +const script: Cardano.PlutusScript = { + __type: Cardano.ScriptType.Plutus, + bytes: HexBlob( + '59079201000033232323232323232323232323232332232323232323232222232325335333006300800530070043333573466E1CD55CEA80124000466442466002006004646464646464646464646464646666AE68CDC39AAB9D500C480008CCCCCCCCCCCC88888888888848CCCCCCCCCCCC00403403002C02802402001C01801401000C008CD4060064D5D0A80619A80C00C9ABA1500B33501801A35742A014666AA038EB9406CD5D0A804999AA80E3AE501B35742A01066A0300466AE85401CCCD54070091D69ABA150063232323333573466E1CD55CEA801240004664424660020060046464646666AE68CDC39AAB9D5002480008CC8848CC00400C008CD40B9D69ABA15002302F357426AE8940088C98C80C8CD5CE01981901809AAB9E5001137540026AE854008C8C8C8CCCD5CD19B8735573AA004900011991091980080180119A8173AD35742A004605E6AE84D5D1280111931901919AB9C033032030135573CA00226EA8004D5D09ABA2500223263202E33573805E05C05826AAE7940044DD50009ABA1500533501875C6AE854010CCD540700808004D5D0A801999AA80E3AE200135742A00460446AE84D5D1280111931901519AB9C02B02A028135744A00226AE8940044D5D1280089ABA25001135744A00226AE8940044D5D1280089ABA25001135744A00226AE8940044D55CF280089BAA00135742A00460246AE84D5D1280111931900E19AB9C01D01C01A101B13263201B3357389201035054350001B135573CA00226EA80054049404448C88C008DD6000990009AA80A911999AAB9F0012500A233500930043574200460066AE880080548C8C8CCCD5CD19B8735573AA004900011991091980080180118061ABA150023005357426AE8940088C98C8054CD5CE00B00A80989AAB9E5001137540024646464646666AE68CDC39AAB9D5004480008CCCC888848CCCC00401401000C008C8C8C8CCCD5CD19B8735573AA0049000119910919800801801180A9ABA1500233500F014357426AE8940088C98C8068CD5CE00D80D00C09AAB9E5001137540026AE854010CCD54021D728039ABA150033232323333573466E1D4005200423212223002004357426AAE79400C8CCCD5CD19B875002480088C84888C004010DD71ABA135573CA00846666AE68CDC3A801A400042444006464C6403866AE700740700680640604D55CEA80089BAA00135742A00466A016EB8D5D09ABA2500223263201633573802E02C02826AE8940044D5D1280089AAB9E500113754002266AA002EB9D6889119118011BAB00132001355012223233335573E0044A010466A00E66442466002006004600C6AAE754008C014D55CF280118021ABA200301313574200222440042442446600200800624464646666AE68CDC3A800A40004642446004006600A6AE84D55CF280191999AB9A3370EA0049001109100091931900899AB9C01201100F00E135573AA00226EA80048C8C8CCCD5CD19B875001480188C848888C010014C01CD5D09AAB9E500323333573466E1D400920042321222230020053009357426AAE7940108CCCD5CD19B875003480088C848888C004014C01CD5D09AAB9E500523333573466E1D40112000232122223003005375C6AE84D55CF280311931900899AB9C01201100F00E00D00C135573AA00226EA80048C8C8CCCD5CD19B8735573AA004900011991091980080180118029ABA15002375A6AE84D5D1280111931900699AB9C00E00D00B135573CA00226EA80048C8CCCD5CD19B8735573AA002900011BAE357426AAE7940088C98C802CCD5CE00600580489BAA001232323232323333573466E1D4005200C21222222200323333573466E1D4009200A21222222200423333573466E1D400D2008233221222222233001009008375C6AE854014DD69ABA135744A00A46666AE68CDC3A8022400C4664424444444660040120106EB8D5D0A8039BAE357426AE89401C8CCCD5CD19B875005480108CC8848888888CC018024020C030D5D0A8049BAE357426AE8940248CCCD5CD19B875006480088C848888888C01C020C034D5D09AAB9E500B23333573466E1D401D2000232122222223005008300E357426AAE7940308C98C8050CD5CE00A80A00900880800780700680609AAB9D5004135573CA00626AAE7940084D55CF280089BAA0012323232323333573466E1D400520022333222122333001005004003375A6AE854010DD69ABA15003375A6AE84D5D1280191999AB9A3370EA0049000119091180100198041ABA135573CA00C464C6401A66AE7003803402C0284D55CEA80189ABA25001135573CA00226EA80048C8C8CCCD5CD19B875001480088C8488C00400CDD71ABA135573CA00646666AE68CDC3A8012400046424460040066EB8D5D09AAB9E500423263200A33573801601401000E26AAE7540044DD500089119191999AB9A3370EA00290021091100091999AB9A3370EA00490011190911180180218031ABA135573CA00846666AE68CDC3A801A400042444004464C6401666AE7003002C02402001C4D55CEA80089BAA0012323333573466E1D40052002212200223333573466E1D40092000212200123263200733573801000E00A00826AAE74DD5000891999AB9A3370E6AAE74DD5000A40004008464C6400866AE700140100092612001490103505431001123230010012233003300200200122212200201' + ), + version: Cardano.PlutusLanguageVersion.V2 +}; + +export const foreignUtxo: Cardano.Utxo[] = [ + [ + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + index: 99, + txId: Cardano.TransactionId('ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + value: { + coins: 4_027_026_465n + } + } + ], + [ + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + index: 100, + txId: Cardano.TransactionId('ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + datum: 1n, + value: { + coins: 4_027_026_465n + } + } + ], + [ + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + index: 200, + txId: Cardano.TransactionId('ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + scriptReference: script, + value: { + coins: 4_027_026_465n + } + } + ] +]; + +const inputResolver: Cardano.InputResolver = { + resolveInput: async (txIn) => + mocks.utxo.find(([hydratedTxIn]) => txIn.txId === hydratedTxIn.txId && txIn.index === hydratedTxIn.index)?.[1] || + foreignUtxo.find(([hydratedTxIn]) => txIn.txId === hydratedTxIn.txId && txIn.index === hydratedTxIn.index)?.[1] || + null +}; + +/** Utility factory for tests to create a GenericTxBuilder with mocked dependencies */ +const createTxBuilder = async ({ + stakeDelegations, + numAddresses = stakeDelegations.length, + useMultiplePaymentKeys = false, + rewardAccounts, + keyAgent, + datumResolver +}: { + stakeDelegations: { + credentialStatus: Cardano.StakeCredentialStatus; + poolId?: Cardano.PoolId; + deposit?: Cardano.Lovelace; + }[]; + numAddresses?: number; + useMultiplePaymentKeys?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rewardAccounts?: any; + keyAgent: InMemoryKeyAgent; + datumResolver?: DatumResolver; +}) => { + let groupedAddresses = await Promise.all( + Array.from({ length: numAddresses }).map(async (_, idx) => + keyAgent.deriveAddress({ index: 0, type: AddressType.External }, idx) + ) + ); + + // Simulate an HD wallet where a each stake key partitions 2 payment keys (2 addresses per stake key) + if (useMultiplePaymentKeys) { + const groupedAddresses2 = await Promise.all( + stakeDelegations.map(async (_, idx) => keyAgent.deriveAddress({ index: 1, type: AddressType.External }, idx)) + ); + groupedAddresses = [...groupedAddresses, ...groupedAddresses2]; + } + + const txBuilderProviders: jest.Mocked = { + addresses: { + add: jest.fn().mockImplementation((...addreses) => groupedAddresses.push(...addreses)), + get: jest.fn().mockResolvedValue(groupedAddresses) + }, + genesisParameters: jest.fn().mockResolvedValue(mocks.genesisParameters), + protocolParameters: jest.fn().mockResolvedValue(mocks.protocolParameters), + rewardAccounts: + rewardAccounts || + jest.fn().mockImplementation(() => + Promise.resolve( + // There can be multiple addresses with the same reward account. Extract the uniq reward accounts + uniqBy(groupedAddresses, ({ rewardAccount }) => rewardAccount) + // Create mock stakeKey/delegation status for each reward account according to the requested stakeDelegations. + // This would normally be done by the wallet.delegation.rewardAccounts + .map(({ rewardAccount: address }, index) => { + const { credentialStatus, poolId, deposit } = stakeDelegations[index] ?? {}; + return { + address, + credentialStatus: credentialStatus ?? Cardano.StakeCredentialStatus.Unregistered, + rewardBalance: mocks.rewardAccountBalance, + ...(poolId ? { delegatee: { nextNextEpoch: { id: poolId } } } : undefined), + ...(deposit && { deposit }) + }; + }) + ) + ), + tip: jest.fn().mockResolvedValue(mocks.ledgerTip), + utxoAvailable: jest.fn().mockResolvedValue(mocks.utxo) + }; + const outputValidator = { + validateOutput: jest.fn().mockResolvedValue({ coinMissing: 0n } as OutputValidation) + }; + const asyncKeyAgent = util.createAsyncKeyAgent(keyAgent); + return { + groupedAddresses, + txBuilder: new GenericTxBuilder({ + bip32Account: await Bip32Account.fromAsyncKeyAgent(asyncKeyAgent), + datumResolver, + inputResolver, + logger: dummyLogger, + outputValidator, + txBuilderProviders, + txEvaluator: mockTxEvaluator, + witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) + }), + txBuilderProviders, + txBuilderWithoutBip32Account: new GenericTxBuilder({ + inputResolver, + logger: dummyLogger, + outputValidator, + txBuilderProviders, + txEvaluator: mockTxEvaluator, + witnesser: util.createBip32Ed25519Witnesser(asyncKeyAgent) + }) + }; +}; + +describe('TxBuilder/plutusScripts', () => { + let txBuilder: GenericTxBuilder; + let keyAgent: InMemoryKeyAgent; + + beforeEach(async () => { + keyAgent = await InMemoryKeyAgent.fromBip39MnemonicWords( + { + chainId: Cardano.ChainIds.Preprod, + getPassphrase: async () => Buffer.from('passphrase'), + mnemonicWords: util.generateMnemonicWords() + }, + { bip32Ed25519: new Crypto.SodiumBip32Ed25519(), logger: dummyLogger } + ); + + const txBuilderFactory = await createTxBuilder({ + keyAgent, + stakeDelegations: [{ credentialStatus: Cardano.StakeCredentialStatus.Unregistered }] + }); + txBuilder = txBuilderFactory.txBuilder; + }); + + afterEach(() => jest.clearAllMocks()); + + it('can set an unresolved input for required selection', async () => { + const tx = await txBuilder + .addInput({ + index: 99, + txId: 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0' as unknown as Cardano.TransactionId + }) + .build() + .inspect(); + + expect( + tx.body.inputs?.some((txIn) => txIn.txId === 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + ).toBeTruthy(); + expect(roundRobinRandomImprove).toHaveBeenCalled(); + }); + + it('can set a resolved input for required selection', async () => { + const tx = await txBuilder + .addInput([ + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + index: 2, + txId: Cardano.TransactionId('0021ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ff00') + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + value: { + coins: 4_000_000n + } + } + ]) + .build() + .inspect(); + + expect( + tx.body.inputs?.some((txIn) => txIn.txId === '0021ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ff00') + ).toBeTruthy(); + expect(roundRobinRandomImprove).toHaveBeenCalled(); + }); + + it('can set an script input for required selection', async () => { + const tx = await txBuilder + .addInput( + { + index: 99, + txId: 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0' as unknown as Cardano.TransactionId + }, + { + datum: 1n, + redeemer: 1n, + script + } + ) + .build() + .inspect(); + + expect( + tx.body.inputs?.some((txIn) => txIn.txId === 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + ).toBeTruthy(); + expect(tx.witness?.datums?.some((datum) => datum === 1n)).toBeTruthy(); + expect(tx.witness?.redeemers?.some((redeemer) => redeemer.data === 1n)).toBeTruthy(); + expect(tx.witness?.scripts?.some((s) => s === script)).toBeTruthy(); + expect(tx.body.scriptIntegrityHash).toEqual('6f33e6b98194924c306d686a35ed5560eb25be58fc72b721a50fc895a7d3f304'); + expect(roundRobinRandomImprove).toHaveBeenCalled(); + }); + + it('can set an script input for required selection with inline datum (unresolved),', async () => { + const tx = await txBuilder + .addInput( + { + index: 100, // Resolves to an input with inline datum + txId: 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0' as unknown as Cardano.TransactionId + }, + { + redeemer: 1n, + script + } + ) + .build() + .inspect(); + + expect( + tx.body.inputs?.some((txIn) => txIn.txId === 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + ).toBeTruthy(); + expect(tx.witness?.datums).toEqual([]); + expect(tx.witness?.redeemers?.some((redeemer) => redeemer.data === 1n)).toBeTruthy(); + expect(tx.witness?.scripts?.some((s) => s === script)).toBeTruthy(); + expect(tx.body.scriptIntegrityHash).toEqual('8b80faf1027b026031a75f38e8fd8b23bdf7abab3455b9cf57f3b4851205af03'); + expect(roundRobinRandomImprove).toHaveBeenCalled(); + }); + + it('can set an script input for required selection with inline datum (resolved),', async () => { + const utxo = [ + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + index: 100, + txId: Cardano.TransactionId('ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + datum: 1n, + value: { + coins: 4_027_026_465n + } + } + ] as Cardano.Utxo; + + const tx = await txBuilder + .addInput(utxo, { + redeemer: 1n, + script + }) + .build() + .inspect(); + + expect( + tx.body.inputs?.some((txIn) => txIn.txId === 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + ).toBeTruthy(); + expect(tx.witness?.datums).toEqual([]); + expect(tx.witness?.redeemers?.some((redeemer) => redeemer.data === 1n)).toBeTruthy(); + expect(tx.witness?.scripts?.some((s) => s === script)).toBeTruthy(); + expect(tx.body.scriptIntegrityHash).toEqual('8b80faf1027b026031a75f38e8fd8b23bdf7abab3455b9cf57f3b4851205af03'); + expect(roundRobinRandomImprove).toHaveBeenCalled(); + }); + + it('can set an script input for required selection with reference script', async () => { + const tx = await txBuilder + .addReferenceInput({ + index: 200, + txId: 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0' as unknown as Cardano.TransactionId + }) + .addInput( + { + index: 99, + txId: 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0' as unknown as Cardano.TransactionId + }, + { + datum: 1n, + redeemer: 1n + } + ) + .build() + .inspect(); + + expect( + tx.body.inputs?.some((txIn) => txIn.txId === 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + ).toBeTruthy(); + expect(tx.witness?.datums?.some((datum) => datum === 1n)).toBeTruthy(); + expect(tx.witness?.redeemers?.some((redeemer) => redeemer.data === 1n)).toBeTruthy(); + expect(tx.witness?.scripts).toEqual([]); + expect(tx.body.scriptIntegrityHash).toEqual('6f33e6b98194924c306d686a35ed5560eb25be58fc72b721a50fc895a7d3f304'); + expect(roundRobinRandomImprove).toHaveBeenCalled(); + }); + + it('can resolve datums via datumResolver', async () => { + const mockResolveDatum: ResolveDatum = jest.fn().mockImplementation(() => Promise.resolve(1n)); + + const mockDatumResolver: DatumResolver = { + resolve: mockResolveDatum + }; + + const customBuilder = ( + await createTxBuilder({ + datumResolver: mockDatumResolver, + keyAgent, + stakeDelegations: [{ credentialStatus: Cardano.StakeCredentialStatus.Unregistered }] + }) + ).txBuilder; + + const tx = await customBuilder + .addReferenceInput({ + index: 200, + txId: 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0' as unknown as Cardano.TransactionId + }) + .addInput( + [ + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + index: 100, + txId: Cardano.TransactionId('ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + datumHash: 'some hash' as Cardano.DatumHash, + value: { + coins: 4_027_026_465n + } + } + ], + { + redeemer: 1n + } + ) + .build() + .inspect(); + + expect(tx.witness?.datums?.some((datum) => datum === 1n)).toBeTruthy(); + expect(tx.witness?.redeemers?.some((redeemer) => redeemer.data === 1n)).toBeTruthy(); + expect(tx.witness?.scripts).toEqual([]); + expect(tx.body.scriptIntegrityHash).toEqual('6f33e6b98194924c306d686a35ed5560eb25be58fc72b721a50fc895a7d3f304'); + expect(mockDatumResolver.resolve).toHaveBeenCalledWith('some hash'); + expect(roundRobinRandomImprove).toHaveBeenCalled(); + }); + + it('can resolve datums if provided via addDatum method', async () => { + const tx = await txBuilder + .addReferenceInput({ + index: 200, + txId: 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0' as unknown as Cardano.TransactionId + }) + .addInput( + [ + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + index: 100, + txId: Cardano.TransactionId('ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + datumHash: 'ee155ace9c40292074cb6aff8c9ccdd273c81648ff1149ef36bcea6ebb8a3e25' as Cardano.DatumHash, + value: { + coins: 4_027_026_465n + } + } + ], + { + redeemer: 1n + } + ) + .addDatum(1n) + .build() + .inspect(); + + expect(tx.witness?.datums?.some((datum) => datum === 1n)).toBeTruthy(); + expect(tx.witness?.redeemers?.some((redeemer) => redeemer.data === 1n)).toBeTruthy(); + expect(tx.witness?.scripts).toEqual([]); + expect(tx.body.scriptIntegrityHash).toEqual('6f33e6b98194924c306d686a35ed5560eb25be58fc72b721a50fc895a7d3f304'); + expect(roundRobinRandomImprove).toHaveBeenCalled(); + }); + + it('set collaterals and return collateral automatically', async () => { + const tx = await txBuilder + .addReferenceInput({ + index: 200, + txId: 'ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0' as unknown as Cardano.TransactionId + }) + .addInput( + [ + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + index: 100, + txId: Cardano.TransactionId('ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + datumHash: 'ee155ace9c40292074cb6aff8c9ccdd273c81648ff1149ef36bcea6ebb8a3e25' as Cardano.DatumHash, + value: { + coins: 4_027_026_465n + } + } + ], + { + redeemer: 1n + } + ) + .addDatum(1n) + .build() + .inspect(); + + expect(tx.body.collaterals).toBeDefined(); + expect(tx.body.collateralReturn).toBeDefined(); + + const values: Array = ( + await Promise.all(tx.body.collaterals!.map((input) => inputResolver.resolveInput(input))) + ) + .filter((x) => x !== null) + .map((out) => out!.value); + + const totalCollateralValue = coalesceValueQuantities(values); + + expect(totalCollateralValue.coins - tx.body.collateralReturn!.value.coins).toEqual(5_000_000n); + expect(totalCollateralValue.assets).toEqual(tx.body.collateralReturn!.value.assets); + expect(roundRobinRandomImprove).toHaveBeenCalled(); + }); + + it('throws when given an input that cant be resolved', async () => { + await expect( + txBuilder + .addInput({ + index: 0, + txId: 'A' as unknown as Cardano.TransactionId + }) + .build() + .inspect() + ).rejects.toThrow('Could not resolve input A#0'); + }); + + it('throws when given an script input with datumHash, datum is not provided and datumResolver is not set', async () => { + await expect( + txBuilder + .addInput( + [ + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + index: 100, + txId: Cardano.TransactionId('ff21ffbaff60ff0cff8cff55ffa6ff6dff78ff78ffaeffceff36ff3fffc5ffe0') + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qqt9c69kjqf0wsnlp7hs8xees5l6pm4yxdqa3hknqr0kfe0htmj4e5t8n885zxm4qzpfzwruqx3ey3f5q8kpkr0gt9ms8dcsz6' + ), + datumHash: 'some hash' as Cardano.DatumHash, + value: { + coins: 4_027_026_465n + } + } + ], + { + redeemer: 1n + } + ) + .build() + .inspect() + ).rejects.toThrow('Cant resolve unknown datums. Datum resolver not set.'); + }); +}); diff --git a/packages/tx-construction/test/tx-builder/mocks.ts b/packages/tx-construction/test/tx-builder/mocks.ts index fedc447e836..ff9d43e20d0 100644 --- a/packages/tx-construction/test/tx-builder/mocks.ts +++ b/packages/tx-construction/test/tx-builder/mocks.ts @@ -1,5 +1,6 @@ import { Cardano } from '@cardano-sdk/core'; import { ChangeAddressResolver, Selection } from '@cardano-sdk/input-selection'; +import { GreedyTxEvaluator } from '../../src'; export class MockChangeAddressResolver implements ChangeAddressResolver { async resolve(selection: Selection) { @@ -10,3 +11,13 @@ export class MockChangeAddressResolver implements ChangeAddressResolver { })); } } + +const getParams = (): Promise => + Promise.resolve({ + maxExecutionUnitsPerTransaction: { + memory: 100, + steps: 200 + } + } as unknown as Cardano.ProtocolParameters); + +export const mockTxEvaluator = new GreedyTxEvaluator(getParams); diff --git a/packages/util-dev/src/mockProviders/mockData.ts b/packages/util-dev/src/mockProviders/mockData.ts index b655b625052..63e67e32532 100644 --- a/packages/util-dev/src/mockProviders/mockData.ts +++ b/packages/util-dev/src/mockProviders/mockData.ts @@ -33,6 +33,10 @@ export const currentEpoch = { export const protocolParameters = { coinsPerUtxoByte: 4310, maxCollateralInputs: 1, + maxExecutionUnitsPerTransaction: { + memory: 100, + steps: 200 + }, maxTxSize: 16_384, maxValueSize: 1000, minFeeCoefficient: 44, diff --git a/packages/wallet/src/Wallets/BaseWallet.ts b/packages/wallet/src/Wallets/BaseWallet.ts index c96fb7b4402..ef6a7febea9 100644 --- a/packages/wallet/src/Wallets/BaseWallet.ts +++ b/packages/wallet/src/Wallets/BaseWallet.ts @@ -93,6 +93,7 @@ import { Cip30DataSignature } from '@cardano-sdk/dapp-connector'; import { Ed25519PublicKey, Ed25519PublicKeyHex } from '@cardano-sdk/crypto'; import { GenericTxBuilder, + GreedyTxEvaluator, InitializeTxProps, InitializeTxResult, InvalidConfigurationError, @@ -552,6 +553,7 @@ export class BaseWallet implements ObservableWallet { : throwError(() => new InvalidConfigurationError('BaseWallet is missing a "handleProvider"')); this.util = createWalletUtil({ + chainHistoryProvider: this.chainHistoryProvider, protocolParameters$: this.protocolParameters$, transactions: this.transactions, utxo: this.utxo @@ -773,6 +775,7 @@ export class BaseWallet implements ObservableWallet { tip: () => this.#firstValueFromSettled(this.tip$), utxoAvailable: () => this.#firstValueFromSettled(this.utxo.available$) }, + txEvaluator: new GreedyTxEvaluator(() => this.#firstValueFromSettled(this.protocolParameters$)), witnesser: this.witnesser }; } diff --git a/packages/wallet/src/services/WalletUtil.ts b/packages/wallet/src/services/WalletUtil.ts index 51cde6e0a80..44d2797f595 100644 --- a/packages/wallet/src/services/WalletUtil.ts +++ b/packages/wallet/src/services/WalletUtil.ts @@ -25,7 +25,8 @@ export interface WalletOutputValidatorContext { protocolParameters$: Observable; } -export type WalletUtilContext = WalletOutputValidatorContext & InputResolverContext; +export type WalletUtilContext = WalletOutputValidatorContext & + InputResolverContext & { chainHistoryProvider: ChainHistoryProvider }; export const createInputResolver = ({ utxo, transactions }: InputResolverContext): Cardano.InputResolver => ({ async resolveInput(input: Cardano.TxIn, options?: Cardano.ResolveOptions) { @@ -130,7 +131,7 @@ export const combineInputResolvers = (...resolvers: Cardano.InputResolver[]): Ca */ export const createWalletUtil = (context: WalletUtilContext) => ({ ...createOutputValidator({ protocolParameters: () => firstValueFrom(context.protocolParameters$) }), - ...createInputResolver(context) + ...combineInputResolvers(createInputResolver(context), createBackendInputResolver(context.chainHistoryProvider)) }); export type WalletUtil = ReturnType; diff --git a/packages/wallet/test/PersonalWallet/methods.test.ts b/packages/wallet/test/PersonalWallet/methods.test.ts index 8a8b0171a3a..4f3bbfc62f9 100644 --- a/packages/wallet/test/PersonalWallet/methods.test.ts +++ b/packages/wallet/test/PersonalWallet/methods.test.ts @@ -194,7 +194,6 @@ describe('BaseWallet methods', () => { describe('creating transactions', () => { const props = { collaterals: new Set([utxo[2][0]]), - inputs: new Set([utxo[1][0]]), mint: new Map([ [AssetId.PXL, 5n], [AssetId.TSLA, 20n] diff --git a/packages/web-extension/src/observableWallet/util.ts b/packages/web-extension/src/observableWallet/util.ts index 0173c166950..61de0dfe40a 100644 --- a/packages/web-extension/src/observableWallet/util.ts +++ b/packages/web-extension/src/observableWallet/util.ts @@ -39,10 +39,22 @@ export const outputBuilderProperties: RemoteApiProperties = { }; export const txBuilderProperties: RemoteApiProperties> = { + addDatum: { + getApiProperties: () => txBuilderProperties, + propType: RemoteApiPropertyType.ApiFactory + }, + addInput: { + getApiProperties: () => txBuilderProperties, + propType: RemoteApiPropertyType.ApiFactory + }, addOutput: { getApiProperties: () => txBuilderProperties, propType: RemoteApiPropertyType.ApiFactory }, + addReferenceInput: { + getApiProperties: () => txBuilderProperties, + propType: RemoteApiPropertyType.ApiFactory + }, build: { getApiProperties: () => ({ inspect: RemoteApiPropertyType.MethodReturningPromise,