diff --git a/.changeset/serious-ladybugs-drum.md b/.changeset/serious-ladybugs-drum.md new file mode 100644 index 0000000..f7e8480 --- /dev/null +++ b/.changeset/serious-ladybugs-drum.md @@ -0,0 +1,7 @@ +--- +"@blaze-cardano/emulator": patch +"@blaze-cardano/sdk": patch +"@blaze-cardano/tx": patch +--- + +Values enforce no 0-quantity assets, redeemers have correct indexes, transaction network id is attached, various emulator patches diff --git a/packages/blaze-emulator/src/emulator.ts b/packages/blaze-emulator/src/emulator.ts index 0989e8c..6190363 100644 --- a/packages/blaze-emulator/src/emulator.ts +++ b/packages/blaze-emulator/src/emulator.ts @@ -120,6 +120,8 @@ export class Emulator { } this.params = params; this.evaluator = evaluator ?? makeUplcEvaluator(params, 1, 1); + this.addUtxo = this.addUtxo.bind(this); + this.removeUtxo = this.removeUtxo.bind(this); } stepForwardBlock(): void { @@ -570,7 +572,7 @@ export class Emulator { // Minimum collateral amount included const minCollateral = BigInt( - this.params.collateralPercentage * Number(body.fee()), + Math.ceil(this.params.collateralPercentage * Number(body.fee())), ); // If any scripts have been invoked, minimum collateral must be included @@ -604,7 +606,7 @@ export class Emulator { .coin()}, MinADA: ${minAda}`, ); - const length = output.toCbor().length / 2; + const length = output.amount().toCbor().length / 2; if (length > this.params.maxValueSize) throw new Error( `Output ${index}'s value exceeds the maximum allowed size. Output: ${length} bytes, Maximum: ${this.params.maxValueSize} bytes`, @@ -684,9 +686,9 @@ export class Emulator { netValue = V.sub(netValue, new Value(fee)); if (!V.empty(netValue)) throw new Error( - `Value not conserved. Leftover Value: ${netValue.coin()}, ${ - netValue.multiasset()?.entries() ?? "" - }`, + `Value not conserved. Leftover Value: ${netValue.coin()}, ${Array.from( + netValue.multiasset()?.entries() ?? [], + )}`, ); this.acceptTransaction(tx); diff --git a/packages/blaze-sdk/src/blaze.ts b/packages/blaze-sdk/src/blaze.ts index 430fb94..41daeff 100644 --- a/packages/blaze-sdk/src/blaze.ts +++ b/packages/blaze-sdk/src/blaze.ts @@ -29,6 +29,7 @@ export class Blaze { const myUtxos = await this.wallet.getUnspentOutputs(); const changeAddress = await this.wallet.getChangeAddress(); return new TxBuilder(params) + .setNetworkId(await this.wallet.getNetworkId()) .addUnspentOutputs(myUtxos) .setChangeAddress(changeAddress) .useEvaluator((x, y) => this.provider.evaluateTransaction(x, y)); diff --git a/packages/blaze-tx/src/tx.ts b/packages/blaze-tx/src/tx.ts index a417238..e114e5b 100644 --- a/packages/blaze-tx/src/tx.ts +++ b/packages/blaze-tx/src/tx.ts @@ -16,6 +16,7 @@ import type { Slot, PoolId, StakeDelegationCertificate, + NetworkId, } from "@blaze-cardano/core"; import { CborSet, @@ -53,6 +54,7 @@ import { StakeDelegation, CertificateType, blake2b_256, + RedeemerTag, } from "@blaze-cardano/core"; import * as value from "./value"; import { micahsSelector } from "./coinSelection"; @@ -101,6 +103,7 @@ export class TxBuilder { private scriptSeen: Set = new Set(); // A set of script hashes that have been processed. private changeAddress?: Address; // The address to send change to, if any. private rewardAddress?: Address; // The reward address to delegate from, if any. + private networkId?: NetworkId; // The network ID for the transaction. private changeOutputIndex?: number; // The index of the change output in the transaction. private plutusData: TransactionWitnessPlutusData = new Set(); // A set of Plutus data for witness purposes. private requiredWitnesses: Set = new Set(); // A set of public keys required for witnessing the transaction. @@ -116,6 +119,10 @@ export class TxBuilder { private additionalSigners = 0; private evaluator?: Evaluator; + private consumedMintHashes: Hash28ByteBase16[] = []; + private consumedWithdrawalHashes: Hash28ByteBase16[] = []; + private consumedSpendInputs: string[] = []; + /** * Constructs a new instance of the TxBuilder class. * Initializes a new transaction body with an empty set of inputs, outputs, and no fee. @@ -130,6 +137,16 @@ export class TxBuilder { ); } + private insertSorted(arr: T[], el: T) { + const index = arr.findIndex((x) => x.localeCompare(el) > 0); + if (index == -1) { + arr.push(el); + } else { + arr.splice(index, 0, el); + } + return index; + } + /** * Sets the change address for the transaction. * This address will receive any remaining funds not allocated to outputs or fees. @@ -159,6 +176,11 @@ export class TxBuilder { return this; } + setNetworkId(networkId: NetworkId) { + this.networkId = networkId; + return this; + } + /** * The additional signers field is used to add additional signing counts for fee calculation. * These will be included in the signing phase at a later stage. @@ -234,6 +256,8 @@ export class TxBuilder { redeemer?: PlutusData, unhashDatum?: PlutusData, ) { + const oref = utxo.input().transactionId() + utxo.input().index().toString(); + const insertIdx = this.insertSorted(this.consumedSpendInputs, oref); // Retrieve the current inputs from the transaction body for manipulation. const inputs = this.body.inputs(); const values = [...inputs.values()]; @@ -245,9 +269,7 @@ export class TxBuilder { val.transactionId() == utxo.input().transactionId(), ) ) { - throw new Error( - "Cannot add duplicate reference input to the transaction", - ); + throw new Error("Cannot add duplicate input to the transaction"); } // Add the new input to the array of inputs and update the transaction body. values.push(utxo.input()); @@ -287,9 +309,17 @@ export class TxBuilder { } // Prepare and add the redeemer to the transaction, including execution units estimation. const redeemers = [...this.redeemers.values()]; + for (const redeemer of redeemers) { + if ( + redeemer.tag() == RedeemerTag.Spend && + redeemer.index() >= insertIdx + ) { + redeemer.setIndex(redeemer.index() + 1n); + } + } redeemers.push( Redeemer.fromCore({ - index: 256, + index: insertIdx, purpose: RedeemerPurpose["spend"], data: redeemer.toCore(), executionUnits: { @@ -337,6 +367,10 @@ export class TxBuilder { assets: Map, redeemer?: PlutusData, ) { + const insertIdx = this.insertSorted( + this.consumedMintHashes, + PolicyIdToHash(policy), + ); // Retrieve the current mint map from the transaction body, or initialize a new one if none exists. const mint: TokenMap = this.body.mint() ?? new Map(); // Iterate over the assets map and add each asset to the mint map under the specified policy. @@ -352,11 +386,19 @@ export class TxBuilder { this.requiredPlutusScripts.add(PolicyIdToHash(policy)); // Retrieve the current list of redeemers and prepare to add a new one. const redeemers = [...this.redeemers.values()]; + for (const redeemer of redeemers) { + if ( + redeemer.tag() == RedeemerTag.Mint && + redeemer.index() >= insertIdx + ) { + redeemer.setIndex(redeemer.index() + 1n); + } + } // Create and add a new redeemer for the minting action, specifying execution units. // Note: Execution units are placeholders and are replaced with actual values during the evaluation phase. redeemers.push( Redeemer.fromCore({ - index: 256, + index: insertIdx, purpose: RedeemerPurpose["mint"], // Specify the purpose of the redeemer as minting. data: redeemer.toCore(), // Convert the provided PlutusData redeemer to its core representation. executionUnits: { @@ -992,6 +1034,12 @@ export class TxBuilder { "Cannot complete transaction without setting change address", ); } + if (!this.networkId) { + throw new Error( + "Cannot complete transaction without setting a network id", + ); + } + this.body.setNetworkId(this.networkId); // Gather all inputs from the transaction body. const inputs = [...this.body.inputs().values()]; // Perform initial checks and preparations for coin selection. @@ -1212,6 +1260,17 @@ export class TxBuilder { * @throws {Error} If the reward account does not have a stake credential or if any other error occurs. */ addWithdrawal(address: RewardAccount, amount: bigint, redeemer?: PlutusData) { + const withdrawalHash = + Address.fromBech32(address).getProps().delegationPart?.hash; + if (!withdrawalHash) { + throw new Error( + "addWithdrawal: The RewardAccount provided does not have an associated stake credential.", + ); + } + const insertIdx = this.insertSorted( + this.consumedWithdrawalHashes, + withdrawalHash, + ); // Retrieve existing withdrawals or initialize a new map if none exist. const withdrawals: Map = this.body.withdrawals() ?? new Map(); @@ -1222,11 +1281,19 @@ export class TxBuilder { // If a redeemer is provided, process it for script validation. if (redeemer) { const redeemers = [...this.redeemers.values()]; + for (const redeemer of redeemers) { + if ( + redeemer.tag() == RedeemerTag.Reward && + redeemer.index() >= insertIdx + ) { + redeemer.setIndex(redeemer.index() + 1n); + } + } // Add the redeemer to the list of redeemers with execution units based on transaction parameters. redeemers.push( Redeemer.fromCore({ - index: 256, // TODO: Determine the correct index for the redeemer. - purpose: RedeemerPurpose["mint"], // TODO: Confirm the purpose of the redeemer. + index: insertIdx, + purpose: RedeemerPurpose["withdrawal"], // TODO: Confirm the purpose of the redeemer. data: redeemer.toCore(), executionUnits: { memory: this.params.maxExecutionUnitsPerTransaction.memory, diff --git a/packages/blaze-tx/src/value.ts b/packages/blaze-tx/src/value.ts index c300983..dd5568f 100644 --- a/packages/blaze-tx/src/value.ts +++ b/packages/blaze-tx/src/value.ts @@ -19,10 +19,11 @@ export function merge(a: Value, b: Value): Value { for (const key of bma.keys()) { const a = ma.get(key); const b = bma.get(key)!; - if (a) { - ma.set(key, a + b); + const newVal = a ? a + b : b; + if (newVal == 0n) { + ma.delete(key); } else { - ma.set(key, b); + ma.set(key, newVal); } } } @@ -186,6 +187,10 @@ export function makeValue( } const tokenMap: Map = new Map(); for (const [asset, qty] of assets) { + if (qty == 0n) + throw new Error( + "Cannot create a Value object with a zero quantity asset.", + ); tokenMap.set(AssetId(asset), qty); } return Value.fromCore({