diff --git a/package-lock.json b/package-lock.json index e9018f9a0..e013d0cb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14943,7 +14943,7 @@ }, "packages/mesh-common": { "name": "@meshsdk/common", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", @@ -14961,11 +14961,11 @@ }, "packages/mesh-contract": { "name": "@meshsdk/contract", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.101", - "@meshsdk/core": "1.9.0-beta.101" + "@meshsdk/common": "1.9.0-beta.102", + "@meshsdk/core": "1.9.0-beta.102" }, "devDependencies": { "@meshsdk/configs": "*", @@ -14976,14 +14976,15 @@ }, "packages/mesh-core": { "name": "@meshsdk/core", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.101", - "@meshsdk/core-cst": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", + "@meshsdk/core-cst": "1.9.0-beta.102", "@meshsdk/provider": "1.9.0-beta.100", - "@meshsdk/transaction": "1.9.0-beta.101", - "@meshsdk/wallet": "1.9.0-beta.101" + "@meshsdk/transaction": "1.9.0-beta.102", + "@meshsdk/wallet": "1.9.0-beta.102", + "scalus": "^0.17.0" }, "devDependencies": { "@meshsdk/configs": "*", @@ -14994,10 +14995,10 @@ }, "packages/mesh-core-csl": { "name": "@meshsdk/core-csl", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", "@sidan-lab/whisky-js-browser": "^1.0.11", "@sidan-lab/whisky-js-nodejs": "^1.0.11", "@types/base32-encoding": "^1.0.2", @@ -15016,7 +15017,7 @@ }, "packages/mesh-core-cst": { "name": "@meshsdk/core-cst", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "0.46.12", @@ -15027,14 +15028,14 @@ "@harmoniclabs/pair": "^1.0.0", "@harmoniclabs/plutus-data": "1.2.6", "@harmoniclabs/uplc": "1.4.1", - "@meshsdk/common": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", "@types/base32-encoding": "^1.0.2", "base32-encoding": "^1.0.0", "bech32": "^2.0.0", "blakejs": "^1.2.1", "bn.js": "^5.2.0", "hash.js": "^1.1.7", - "scalus": "^0.14.2" + "scalus": "^0.17.0" }, "devDependencies": { "@meshsdk/configs": "*", @@ -15046,16 +15047,28 @@ "typescript": "^5.3.3" } }, + "packages/mesh-core-cst/node_modules/scalus": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/scalus/-/scalus-0.17.0.tgz", + "integrity": "sha512-74mD4wL1vw4GVh2ECemQhoB3q5Smwj5S8W7OG1j7OWuLkwJ0Mvz4LFhZPA9rZjpNvanZoKiDqc3S/qGD65uRYg==", + "license": "Apache-2.0" + }, + "packages/mesh-core/node_modules/scalus": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/scalus/-/scalus-0.17.0.tgz", + "integrity": "sha512-74mD4wL1vw4GVh2ECemQhoB3q5Smwj5S8W7OG1j7OWuLkwJ0Mvz4LFhZPA9rZjpNvanZoKiDqc3S/qGD65uRYg==", + "license": "Apache-2.0" + }, "packages/mesh-transaction": { "name": "@meshsdk/transaction", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "0.46.12", "@cardano-sdk/input-selection": "0.14.28", "@cardano-sdk/util": "0.17.1", - "@meshsdk/common": "1.9.0-beta.101", - "@meshsdk/core-cst": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", + "@meshsdk/core-cst": "1.9.0-beta.102", "json-bigint": "^1.0.0" }, "devDependencies": { @@ -15068,12 +15081,12 @@ }, "packages/mesh-wallet": { "name": "@meshsdk/wallet", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.101", - "@meshsdk/core-cst": "1.9.0-beta.101", - "@meshsdk/transaction": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", + "@meshsdk/core-cst": "1.9.0-beta.102", + "@meshsdk/transaction": "1.9.0-beta.102", "@simplewebauthn/browser": "^13.0.0" }, "devDependencies": { @@ -15086,7 +15099,7 @@ }, "scripts/mesh-cli": { "name": "meshjs", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { "@sidan-lab/cardano-bar": "^0.0.7", diff --git a/packages/configs/typescript/base.json b/packages/configs/typescript/base.json index 1f621d565..bbf716503 100644 --- a/packages/configs/typescript/base.json +++ b/packages/configs/typescript/base.json @@ -23,6 +23,7 @@ "sourceMap": true, "allowJs": true, "allowSyntheticDefaultImports": true, - "noUnusedLocals": false + "noUnusedLocals": false, + "types": ["node", "jest"] } } \ No newline at end of file diff --git a/packages/mesh-core-cst/package.json b/packages/mesh-core-cst/package.json index 32e1e4809..ccb6626df 100644 --- a/packages/mesh-core-cst/package.json +++ b/packages/mesh-core-cst/package.json @@ -51,7 +51,7 @@ "blakejs": "^1.2.1", "bn.js": "^5.2.0", "hash.js": "^1.1.7", - "scalus": "^0.14.2" + "scalus": "^0.17.0" }, "overrides": { "@cardano-sdk/crypto": { diff --git a/packages/mesh-core-cst/src/utils/converter.ts b/packages/mesh-core-cst/src/utils/converter.ts index 48f170d82..36449e2fb 100644 --- a/packages/mesh-core-cst/src/utils/converter.ts +++ b/packages/mesh-core-cst/src/utils/converter.ts @@ -466,3 +466,41 @@ export const toPlutusLanguageVersion = ( return PlutusLanguageVersion.V3; } }; + +export const utxosToCborMap = (utxos: UTxO[]): string => { + const cborWriter = new Serialization.CborWriter(); + cborWriter.writeStartMap(utxos.length); + for (const utxo of utxos) { + const cardanoUtxo = toTxUnspentOutput(utxo); + cborWriter.writeEncodedValue( + Buffer.from(cardanoUtxo.input().toCbor(), "hex"), + ); + cborWriter.writeEncodedValue( + Buffer.from(cardanoUtxo.output().toCbor(), "hex"), + ); + } + return cborWriter.encodeAsHex(); +}; + +export const cborMapToUtxos = (cborMaps: string[]): UTxO[] => { + const utxos: UTxO[] = []; + for (const cborMap of cborMaps) { + const cborReader = new Serialization.CborReader( + Buffer.from(cborMap, "hex"), + ); + const mapLength = cborReader.readStartMap(); + if (!mapLength) { + throw new Error("Invalid CBOR map: expected a map of UTxOs"); + } + for (let i = 0; i < mapLength; i++) { + const inputCbor = cborReader.readEncodedValue(); + const outputCbor = cborReader.readEncodedValue(); + const utxo = Serialization.TransactionUnspentOutput.fromCore([ + Serialization.TransactionInput.fromCbor(inputCbor).toCore(), + Serialization.TransactionOutput.fromCbor(outputCbor).toCore(), + ]); + utxos.push(fromTxUnspentOutput(utxo)); + } + } + return utxos; +}; diff --git a/packages/mesh-core/package.json b/packages/mesh-core/package.json index ac44f6983..f48f0c50f 100644 --- a/packages/mesh-core/package.json +++ b/packages/mesh-core/package.json @@ -37,7 +37,8 @@ "@meshsdk/core-cst": "1.9.0-beta.102", "@meshsdk/provider": "1.9.0-beta.100", "@meshsdk/transaction": "1.9.0-beta.102", - "@meshsdk/wallet": "1.9.0-beta.102" + "@meshsdk/wallet": "1.9.0-beta.102", + "scalus": "^0.17.0" }, "prettier": "@meshsdk/configs/prettier", "publishConfig": { diff --git a/packages/mesh-core/src/utils/emulator.ts b/packages/mesh-core/src/utils/emulator.ts new file mode 100644 index 000000000..9e03c978d --- /dev/null +++ b/packages/mesh-core/src/utils/emulator.ts @@ -0,0 +1,369 @@ +// Use `import type` for scalus types, `require()` at runtime since scalus is CJS +import type { Scalus, SlotConfig, SubmitResult } from "scalus"; +import { bech32 } from "@scure/base"; +import cbor from "cbor"; +import { Emulator } from "scalus"; + +import type { + AccountInfo, + Action, + Asset, + AssetMetadata, + BlockInfo, + GovernanceProposalInfo, + IEvaluator, + IFetcher, + IFetcherOptions, + ISubmitter, + Protocol, + TransactionInfo, + UTxO, +} from "@meshsdk/common"; +import { + DEFAULT_PROTOCOL_PARAMETERS, + DEFAULT_V1_COST_MODEL_LIST, + DEFAULT_V2_COST_MODEL_LIST, + DEFAULT_V3_COST_MODEL_LIST, +} from "@meshsdk/common"; +import { utxosToCborMap } from "@meshsdk/core-cst"; + +// Scalus is CJS so we use dynamic import at construction time +let ScalusLib: typeof import("scalus") | undefined; + +/** + * Scalus Emulator provider for MeshJS. + * Implements IFetcher + ISubmitter + IEvaluator backed by a local Scalus Cardano emulator. + * + * Usage: + * ```ts + * import { ScalusEmulator } from "@meshsdk/provider"; + * import { Emulator, SlotConfig } from "scalus"; + * + * const emulator = Emulator.withAddresses([aliceAddr], SlotConfig.preview); + * const provider = new ScalusEmulator(emulator, SlotConfig.preview); + * const txBuilder = new MeshTxBuilder({ fetcher: provider, submitter: provider, evaluator: provider }); + * ``` + */ +export class ScalusEmulator implements IFetcher, ISubmitter, IEvaluator { + public emulator: Emulator; + private slotConfig: SlotConfig; + private protocolParams: Protocol; + private costModels: number[][]; + + constructor( + initialUtxos: UTxO[], + slotConfig: SlotConfig, + options?: { + protocolParams?: Protocol; + costModels?: { + PlutusV1?: number[]; + PlutusV2?: number[]; + PlutusV3?: number[]; + }; + }, + ) { + this.emulator = new Emulator( + Buffer.from(utxosToCborMap(initialUtxos), "hex"), + slotConfig, + ); + + this.slotConfig = slotConfig; + this.protocolParams = + options?.protocolParams ?? DEFAULT_PROTOCOL_PARAMETERS; + this.costModels = [ + options?.costModels?.PlutusV1 ?? DEFAULT_V1_COST_MODEL_LIST, + options?.costModels?.PlutusV2 ?? DEFAULT_V2_COST_MODEL_LIST, + options?.costModels?.PlutusV3 ?? DEFAULT_V3_COST_MODEL_LIST, + ]; + + // Eagerly load the scalus module + if (!ScalusLib) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + ScalusLib = require("scalus") as typeof import("scalus"); + } + } + + // --------------------------------------------------------------------------- + // IFetcher + // --------------------------------------------------------------------------- + + async fetchAddressUTxOs(address: string, asset?: string): Promise { + const entries = this.emulator.getUtxosForAddress(address); + const utxos = entries.map((e) => decodeUtxoEntry(e, address)); + if (asset) { + return utxos.filter((u) => u.output.amount.some((a) => a.unit === asset)); + } + return utxos; + } + + async fetchUTxOs(hash: string, index?: number): Promise { + const allEntries = this.emulator.getAllUtxos(); + const utxos: UTxO[] = []; + for (const entry of allEntries) { + const utxo = decodeUtxoEntry(entry); + if (utxo.input.txHash === hash) { + if (index === undefined || utxo.input.outputIndex === index) { + utxos.push(utxo); + } + } + } + return utxos; + } + + async fetchProtocolParameters(_epoch: number): Promise { + return this.protocolParams; + } + + // --- Unsupported IFetcher methods (emulator doesn't track this data) --- + + async fetchAccountInfo(_address: string): Promise { + throw new Error("fetchAccountInfo not supported by ScalusEmulator"); + } + + async fetchAddressTxs( + _address: string, + _options?: IFetcherOptions, + ): Promise { + throw new Error("fetchAddressTxs not supported by ScalusEmulator"); + } + + async fetchAssetAddresses( + _asset: string, + ): Promise<{ address: string; quantity: string }[]> { + throw new Error("fetchAssetAddresses not supported by ScalusEmulator"); + } + + async fetchAssetMetadata(_asset: string): Promise { + throw new Error("fetchAssetMetadata not supported by ScalusEmulator"); + } + + async fetchBlockInfo(_hash: string): Promise { + throw new Error("fetchBlockInfo not supported by ScalusEmulator"); + } + + async fetchCollectionAssets( + _policyId: string, + _cursor?: number | string, + ): Promise<{ assets: Asset[]; next?: string | number | null }> { + throw new Error("fetchCollectionAssets not supported by ScalusEmulator"); + } + + async fetchTxInfo(_hash: string): Promise { + throw new Error("fetchTxInfo not supported by ScalusEmulator"); + } + + async fetchGovernanceProposal( + _txHash: string, + _certIndex: number, + ): Promise { + throw new Error("fetchGovernanceProposal not supported by ScalusEmulator"); + } + + async get(_url: string): Promise { + throw new Error("get not supported by ScalusEmulator"); + } + + // --------------------------------------------------------------------------- + // ISubmitter + // --------------------------------------------------------------------------- + + async submitTx(tx: string): Promise { + const txBytes = hexToBytes(tx); + const result: SubmitResult = this.emulator.submitTx(txBytes); + if (!result.isSuccess) { + const logs = result.logs?.join("\n") ?? ""; + throw new Error( + `Transaction rejected: ${result.error}${logs ? `\nLogs:\n${logs}` : ""}`, + ); + } + return result.txHash!; + } + + // --------------------------------------------------------------------------- + // IEvaluator + // --------------------------------------------------------------------------- + + async evaluateTx( + tx: string, + additionalUtxos?: UTxO[], + ): Promise[]> { + const txBytes = hexToBytes(tx); + + const utxoMapBytes = this.emulator.getAllUtxos(); + let utxos: UTxO[] = utxoMapBytes.map((e) => decodeUtxoEntry(e)); + if (additionalUtxos) { + utxos = utxos.concat(additionalUtxos); + } + const utxoMapCbor = Buffer.from(utxosToCborMap(utxos), "hex"); + const scalusSlotConfig = new ScalusLib!.SlotConfig( + this.slotConfig.slotToTime(0), + 0, + 1000, + ); + let redeemers: Scalus.Redeemer[]; + try { + redeemers = ScalusLib!.Scalus.evalPlutusScripts( + txBytes, + utxoMapCbor, + scalusSlotConfig, + this.costModels, + ); + } catch (error) { + throw error; + } + + const tagMap: Record = { + Spend: "SPEND", + Mint: "MINT", + Cert: "CERT", + Reward: "REWARD", + Voting: "VOTE", + Proposing: "PROPOSE", + }; + + return redeemers.map( + (r): Omit => ({ + tag: tagMap[r.tag] || "SPEND", + index: r.index, + budget: { + mem: Number(r.budget.memory), + steps: Number(r.budget.steps), + }, + }), + ); + } +} + +// --------------------------------------------------------------------------- +// CBOR Decoding Helpers +// --------------------------------------------------------------------------- + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes: Uint8Array | Buffer): string { + return Buffer.from(bytes).toString("hex"); +} + +/** + * Convert raw Cardano address bytes to bech32 string. + * Header byte determines address type and network. + */ +function addressBytesToBech32(addrBytes: Uint8Array): string { + const header = addrBytes[0]!; + const networkId = header & 0x0f; + const prefix = + networkId === 1 + ? "addr" // mainnet + : "addr_test"; // testnet + + // bech32 encode the full address bytes (header + payload) + const words = bech32.toWords(addrBytes); + return bech32.encode(prefix, words, 1023); +} + +/** + * Decode a Cardano CBOR value field into Asset[]. + * Value is either: uint (lovelace only) or [uint, multiasset_map] + */ +function decodeValue(value: unknown): Asset[] { + if (typeof value === "number" || typeof value === "bigint") { + return [{ unit: "lovelace", quantity: String(value) }]; + } + if (Array.isArray(value)) { + const [lovelace, multiAsset] = value; + const assets: Asset[] = [{ unit: "lovelace", quantity: String(lovelace) }]; + if (multiAsset instanceof Map) { + for (const [policyId, assetMap] of multiAsset) { + const policyHex = bytesToHex(policyId as Uint8Array); + if (assetMap instanceof Map) { + for (const [assetName, quantity] of assetMap) { + const nameHex = bytesToHex(assetName as Uint8Array); + assets.push({ + unit: policyHex + nameHex, + quantity: String(quantity), + }); + } + } + } + } + return assets; + } + return [{ unit: "lovelace", quantity: "0" }]; +} + +/** + * Decode a single CBOR-encoded UTxO entry (Map with one key-value pair) + * from the Scalus emulator into a MeshJS UTxO. + * + * @param cborBytes - CBOR encoded Map[TransactionInput, TransactionOutput] + * @param knownAddress - If provided, skip address decoding (optimization for fetchAddressUTxOs) + */ +function decodeUtxoEntry(cborBytes: Uint8Array, knownAddress?: string): UTxO { + const decoded = cbor.decode(cborBytes) as Map; + const entry = Array.from(decoded.entries())[0]!; + const [txIn, txOut] = entry; + + // Decode TransactionInput: [hash_bytes, index] + const txInArr = txIn as [Uint8Array, number]; + const txHash = bytesToHex(txInArr[0]); + const outputIndex = txInArr[1]; + + // Decode TransactionOutput (Babbage era uses Map format) + let address: string; + let amount: Asset[]; + let dataHash: string | undefined; + let plutusData: string | undefined; + let scriptRef: string | undefined; + + if (txOut instanceof Map) { + // Babbage-era map format: {0: address, 1: value, ?2: datumOption, ?3: scriptRef} + const addrBytes = txOut.get(0) as Uint8Array; + address = knownAddress ?? addressBytesToBech32(addrBytes); + amount = decodeValue(txOut.get(1)); + + const datumOption = txOut.get(2); + if (datumOption != null && Array.isArray(datumOption)) { + const [tag, datum] = datumOption; + if (tag === 0) { + // DatumHash + dataHash = bytesToHex(datum as Uint8Array); + } else if (tag === 1) { + // Inline datum — encode back to CBOR hex + plutusData = bytesToHex(cbor.encode(datum)); + } + } + + const scriptRefVal = txOut.get(3); + if (scriptRefVal != null) { + // ScriptRef is CBOR-tagged, encode back to hex + scriptRef = bytesToHex(cbor.encode(scriptRefVal)); + } + } else if (Array.isArray(txOut)) { + // Shelley-era array format: [address, value, ?datumHash] + const addrBytes = txOut[0] as Uint8Array; + address = knownAddress ?? addressBytesToBech32(addrBytes); + amount = decodeValue(txOut[1]); + if (txOut[2]) { + dataHash = bytesToHex(txOut[2] as Uint8Array); + } + } else { + throw new Error("Unexpected TransactionOutput format"); + } + + return { + input: { txHash, outputIndex }, + output: { + address: address!, + amount, + dataHash, + plutusData, + scriptRef, + }, + }; +} diff --git a/packages/mesh-core/src/utils/index.ts b/packages/mesh-core/src/utils/index.ts index a8902c2ea..f711f6361 100644 --- a/packages/mesh-core/src/utils/index.ts +++ b/packages/mesh-core/src/utils/index.ts @@ -2,3 +2,5 @@ export * from "./resolver"; export * from "./deserializer"; export * from "./serializer"; export * from "./blueprint"; +export * from "./emulator"; +export * from "./emulator"; diff --git a/packages/mesh-core/src/utils/serializer.ts b/packages/mesh-core/src/utils/serializer.ts index a7ff97c6b..f1e74925e 100644 --- a/packages/mesh-core/src/utils/serializer.ts +++ b/packages/mesh-core/src/utils/serializer.ts @@ -1,8 +1,5 @@ -import JSONBig from "json-bigint"; - import { BuilderData, - Data, DeserializedAddress, NativeScript, PlutusDataType, diff --git a/packages/mesh-core/test/emulator.test.ts b/packages/mesh-core/test/emulator.test.ts new file mode 100644 index 000000000..39c622d7c --- /dev/null +++ b/packages/mesh-core/test/emulator.test.ts @@ -0,0 +1,726 @@ +import { Emulator, SlotConfig } from "scalus"; + +import { + applyCborEncoding, + MeshTxBuilder, + NativeScript, + resolveNativeScriptHash, + resolveNativeScriptHex, + resolvePaymentKeyHash, + resolveScriptHash, + ScalusEmulator, + UTxO, +} from "@meshsdk/core"; +import { AppWallet } from "@meshsdk/wallet"; + +const TEST_MNEMONIC = [ + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", +]; + +const alwaysSucceedCbor = applyCborEncoding( + "58340101002332259800a518a4d153300249011856616c696461746f722072657475726e65642066616c736500136564004ae715cd01", +); + +async function createTestSetup(lovelacePerAddress = 10_000_000_000n) { + const slotConfig = SlotConfig.preview; + const wallet = new AppWallet({ + networkId: 0, + key: { type: "mnemonic", words: TEST_MNEMONIC }, + }); + await wallet.init(); + const address = wallet.getPaymentAddress(); + + const currentSlot = slotConfig.timeToSlot(Date.now()); + + const provider = new ScalusEmulator( + [ + { + input: { + txHash: + "0000000000000000000000000000000000000000000000000000000000000000", + outputIndex: 0, + }, + output: { + address, + amount: [ + { unit: "lovelace", quantity: lovelacePerAddress.toString() }, + ], + }, + }, + ], + slotConfig, + ); + + provider.emulator.setSlot(currentSlot); + + const newTxBuilder = () => + new MeshTxBuilder({ + fetcher: provider, + submitter: provider, + evaluator: provider, + }); + + return { + wallet, + address, + provider, + emulator: provider.emulator, + slotConfig, + newTxBuilder, + }; +} + +describe("ScalusEmulator", () => { + describe("Basic payment lifecycle", () => { + it("should build, sign, submit and confirm a simple payment", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + expect(utxos.length).toBeGreaterThan(0); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "5000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash).toBeDefined(); + expect(txHash.length).toBe(64); + }); + + it("should reflect UTxO changes after submission", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxosBefore = await provider.fetchAddressUTxOs(address); + const totalBefore = utxosBefore.reduce( + (sum, u) => + sum + + BigInt(u.output.amount.find((a) => a.unit === "lovelace")!.quantity), + 0n, + ); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "3000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxosBefore) + .complete(); + + const signedTx = await wallet.signTx(txHex); + await provider.submitTx(signedTx); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const totalAfter = utxosAfter.reduce( + (sum, u) => + sum + + BigInt(u.output.amount.find((a) => a.unit === "lovelace")!.quantity), + 0n, + ); + + expect(utxosAfter.length).toBeGreaterThan(0); + // Total should decrease by fees + expect(totalAfter).toBeLessThan(totalBefore); + expect(totalAfter).toBeGreaterThan(totalBefore - 1_000_000n); + }); + + it("should chain multiple transactions", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos1 = await provider.fetchAddressUTxOs(address); + const txHex1 = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos1) + .complete(); + const signed1 = await wallet.signTx(txHex1); + const hash1 = await provider.submitTx(signed1); + + const utxos2 = await provider.fetchAddressUTxOs(address); + const txHex2 = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos2) + .complete(); + const signed2 = await wallet.signTx(txHex2); + const hash2 = await provider.submitTx(signed2); + + expect(hash1).not.toBe(hash2); + + const utxos3 = await provider.fetchAddressUTxOs(address); + expect(utxos3.length).toBeGreaterThan(0); + // Original UTxOs should be consumed + const utxo1Hashes = new Set(utxos1.map((u) => u.input.txHash)); + const utxo3Hashes = new Set(utxos3.map((u) => u.input.txHash)); + for (const h of utxo1Hashes) { + expect(utxo3Hashes.has(h)).toBe(false); + } + }); + }); + + describe("Native script minting", () => { + it("should mint tokens with a native script and verify UTxOs", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const keyHash = resolvePaymentKeyHash(address); + const nativeScript: NativeScript = { type: "sig", keyHash }; + const scriptCbor = resolveNativeScriptHex(nativeScript); + const policyId = resolveNativeScriptHash(nativeScript); + const tokenNameHex = Buffer.from("TestToken").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .mint("1000", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "1000" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const tokenUtxo = utxosAfter.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenUtxo).toBeDefined(); + expect( + tokenUtxo!.output.amount.find((a) => a.unit === unit)!.quantity, + ).toBe("1000"); + }); + + it("should mint and then burn tokens", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const keyHash = resolvePaymentKeyHash(address); + const nativeScript: NativeScript = { type: "sig", keyHash }; + const scriptCbor = resolveNativeScriptHex(nativeScript); + const policyId = resolveNativeScriptHash(nativeScript); + const tokenNameHex = Buffer.from("BurnToken").toString("hex"); + const unit = policyId + tokenNameHex; + + // Step 1: Mint + const utxos = await provider.fetchAddressUTxOs(address); + const mintTxHex = await newTxBuilder() + .mint("1000", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "1000" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedMint = await wallet.signTx(mintTxHex); + await provider.submitTx(signedMint); + + // Step 2: Burn half + const utxosAfterMint = await provider.fetchAddressUTxOs(address); + const tokenUtxo = utxosAfterMint.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenUtxo).toBeDefined(); + + const burnTxHex = await newTxBuilder() + .mint("-500", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txIn( + tokenUtxo!.input.txHash, + tokenUtxo!.input.outputIndex, + tokenUtxo!.output.amount, + tokenUtxo!.output.address, + ) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "500" }, + ]) + .changeAddress(address) + .selectUtxosFrom( + utxosAfterMint.filter( + (u) => + u.input.txHash !== tokenUtxo!.input.txHash || + u.input.outputIndex !== tokenUtxo!.input.outputIndex, + ), + ) + .complete(); + + const signedBurn = await wallet.signTx(burnTxHex); + await provider.submitTx(signedBurn); + + // Step 3: Verify remaining + const utxosFinal = await provider.fetchAddressUTxOs(address); + let totalTokens = 0n; + for (const u of utxosFinal) { + const tokenAsset = u.output.amount.find((a) => a.unit === unit); + if (tokenAsset) totalTokens += BigInt(tokenAsset.quantity); + } + expect(totalTokens).toBe(500n); + }); + }); + + describe("Plutus script evaluation", () => { + it("should evaluate and submit a plutus minting transaction", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const policyId = resolveScriptHash(alwaysSucceedCbor, "V3"); + const tokenNameHex = Buffer.from("PlutusToken").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .mintPlutusScriptV3() + .mint("100", policyId, tokenNameHex) + .mintRedeemerValue("") + .mintingScript(alwaysSucceedCbor) + .txInCollateral( + utxos[0]!.input.txHash, + utxos[0]!.input.outputIndex, + utxos[0]!.output.amount, + utxos[0]!.output.address, + ) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "100" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const tokenUtxo = utxosAfter.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenUtxo).toBeDefined(); + expect( + tokenUtxo!.output.amount.find((a) => a.unit === unit)!.quantity, + ).toBe("100"); + }); + + it("should evaluate a plutus spending transaction", async () => { + const { wallet, address, provider, newTxBuilder, emulator } = + await createTestSetup(); + + const policyId = resolveScriptHash(alwaysSucceedCbor, "V3"); + const tokenNameHex = Buffer.from("SpendTest").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + // Step 1: Mint tokens using plutus script + const mintTxHex = await newTxBuilder() + .mintPlutusScriptV3() + .mint("50", policyId, tokenNameHex) + .mintRedeemerValue("") + .mintingScript(alwaysSucceedCbor) + .txInCollateral( + utxos[0]!.input.txHash, + utxos[0]!.input.outputIndex, + utxos[0]!.output.amount, + utxos[0]!.output.address, + ) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "50" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + const signedMint = await wallet.signTx(mintTxHex); + const mintHash = await provider.submitTx(signedMint); + expect(mintHash.length).toBe(64); + + // Step 2: Verify the minted tokens via fetchUTxOs + const mintedUtxos = await provider.fetchUTxOs(mintHash); + expect(mintedUtxos.length).toBeGreaterThan(0); + const tokenOutput = mintedUtxos.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenOutput).toBeDefined(); + }); + }); + + describe("Validity intervals", () => { + it("should build and submit a transaction with TTL", async () => { + const { wallet, address, provider, newTxBuilder, slotConfig } = + await createTestSetup(); + + const currentSlot = slotConfig.timeToSlot(Date.now()); + const ttlSlot = currentSlot + 300; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .invalidHereafter(ttlSlot) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + }); + + it("should build and submit with both validity bounds", async () => { + const { wallet, address, provider, newTxBuilder, slotConfig } = + await createTestSetup(); + + const currentSlot = slotConfig.timeToSlot(Date.now()); + const validFrom = currentSlot - 10; + const validTo = currentSlot + 300; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .invalidBefore(validFrom) + .invalidHereafter(validTo) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + }); + + it("should reject an expired transaction", async () => { + const { wallet, address, provider, newTxBuilder, slotConfig } = + await createTestSetup(); + + const currentSlot = slotConfig.timeToSlot(Date.now()); + const expiredSlot = currentSlot - 100; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .invalidHereafter(expiredSlot) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + await expect(provider.submitTx(signedTx)).rejects.toThrow(); + }); + + it("should reject a transaction before validity start", async () => { + const { wallet, address, provider, newTxBuilder, slotConfig } = + await createTestSetup(); + + const currentSlot = slotConfig.timeToSlot(Date.now()); + const futureStart = currentSlot + 300; + const futureTtl = currentSlot + 600; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .invalidBefore(futureStart) + .invalidHereafter(futureTtl) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + await expect(provider.submitTx(signedTx)).rejects.toThrow(); + }); + }); + + describe("Transaction composition", () => { + it("should build a transaction with multiple outputs", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .txOut(address, [{ unit: "lovelace", quantity: "3000000" }]) + .txOut(address, [{ unit: "lovelace", quantity: "4000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const amounts = utxosAfter.map((u) => + BigInt(u.output.amount.find((a) => a.unit === "lovelace")!.quantity), + ); + expect(amounts.filter((a) => a === 2_000_000n).length).toBeGreaterThan(0); + expect(amounts.filter((a) => a === 3_000_000n).length).toBeGreaterThan(0); + expect(amounts.filter((a) => a === 4_000_000n).length).toBeGreaterThan(0); + }); + + it("should combine minting and payment in a single transaction", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const keyHash = resolvePaymentKeyHash(address); + const nativeScript: NativeScript = { type: "sig", keyHash }; + const scriptCbor = resolveNativeScriptHex(nativeScript); + const policyId = resolveNativeScriptHash(nativeScript); + const tokenNameHex = Buffer.from("ComboToken").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .mint("777", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txOut(address, [ + { unit: "lovelace", quantity: "5000000" }, + { unit, quantity: "777" }, + ]) + .txOut(address, [{ unit: "lovelace", quantity: "3000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const tokenUtxo = utxosAfter.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenUtxo).toBeDefined(); + expect( + tokenUtxo!.output.amount.find((a) => a.unit === unit)!.quantity, + ).toBe("777"); + }); + + it("should build a transaction with metadata", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .metadataValue(674, "Hello from ScalusEmulator test") + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + }); + }); + + describe("Error handling", () => { + it("should reject a transaction with insufficient funds", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(5_000_000n); + + const utxos = await provider.fetchAddressUTxOs(address); + + await expect( + newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "100000000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(), + ).rejects.toThrow(); + }); + + it("should reject submitting an unsigned transaction", async () => { + const { address, provider, newTxBuilder } = await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + await expect(provider.submitTx(txHex)).rejects.toThrow(); + }); + + it("should throw on unsupported fetcher methods", async () => { + const { provider } = await createTestSetup(); + + await expect(provider.fetchAccountInfo("addr_test1...")).rejects.toThrow( + "not supported", + ); + await expect(provider.fetchBlockInfo("abc")).rejects.toThrow( + "not supported", + ); + await expect(provider.fetchTxInfo("abc")).rejects.toThrow( + "not supported", + ); + }); + }); + + describe("UTxO querying", () => { + it("should fetch UTxOs by transaction hash after submission", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "7000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + + const txUtxos = await provider.fetchUTxOs(txHash); + expect(txUtxos.length).toBeGreaterThan(0); + expect(txUtxos.every((u) => u.input.txHash === txHash)).toBe(true); + + const has7Ada = txUtxos.some((u) => + u.output.amount.some( + (a) => a.unit === "lovelace" && a.quantity === "7000000", + ), + ); + expect(has7Ada).toBe(true); + }); + + it("should filter UTxOs by asset unit", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const keyHash = resolvePaymentKeyHash(address); + const nativeScript: NativeScript = { type: "sig", keyHash }; + const scriptCbor = resolveNativeScriptHex(nativeScript); + const policyId = resolveNativeScriptHash(nativeScript); + const tokenNameHex = Buffer.from("FilterToken").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .mint("200", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "200" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + await provider.submitTx(signedTx); + + const allUtxos = await provider.fetchAddressUTxOs(address); + const filteredUtxos = await provider.fetchAddressUTxOs(address, unit); + + expect(filteredUtxos.length).toBeLessThan(allUtxos.length); + expect(filteredUtxos.length).toBe(1); + expect( + filteredUtxos[0]!.output.amount.find((a) => a.unit === unit)!.quantity, + ).toBe("200"); + }); + + it("should allow inputting additional UTxOs for script evaluation", async () => { + const { address, provider, emulator } = await createTestSetup(); + + const policyId = resolveScriptHash(alwaysSucceedCbor, "V3"); + const tokenNameHex = Buffer.from("SpendTest").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + const testUtxo: UTxO = { + input: { + txHash: + "ffe00432c78714fbd9c1784a6b574b0b11bd7c9dedb305aa7f55593505607539", + outputIndex: 0, + }, + output: { + address, + amount: [{ unit: "lovelace", quantity: "2000000" }], + }, + }; + // Step 1: Mint tokens using plutus script + const mintTxHex = await new MeshTxBuilder({ + fetcher: provider, + }) + .txIn( + testUtxo.input.txHash, + testUtxo.input.outputIndex, + testUtxo.output.amount, + testUtxo.output.address, + 0, + ) + .mintPlutusScriptV3() + .mint("50", policyId, tokenNameHex) + .mintRedeemerValue("") + .mintingScript(alwaysSucceedCbor) + .txInCollateral( + utxos[0]!.input.txHash, + utxos[0]!.input.outputIndex, + utxos[0]!.output.amount, + utxos[0]!.output.address, + ) + .setFee("2000000") + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "50" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + const evaluateResult = await provider.evaluateTx(mintTxHex, [testUtxo]); + expect(evaluateResult).toEqual([ + { + tag: "MINT", + index: 0, + budget: { mem: 2001, steps: 380149 }, + }, + ]); + }); + }); +}); diff --git a/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/cardano-sdk-adapter.ts b/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/cardano-sdk-adapter.ts index fa4ef0307..0f75f7f43 100644 --- a/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/cardano-sdk-adapter.ts +++ b/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/cardano-sdk-adapter.ts @@ -63,7 +63,7 @@ export class BuilderCallbacksSdkBridge change: selectionSkeleton.change.map((output) => CSDKOutputToMeshOutput(output), ), - fee: selectionSkeleton.fee, + fee: BigInt(1_000_000_000), }); return { diff --git a/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/largest-first-selector.ts b/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/largest-first-selector.ts index b9298dcf1..0fd52a5d1 100644 --- a/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/largest-first-selector.ts +++ b/packages/mesh-transaction/src/mesh-tx-builder/coin-selection/largest-first-selector.ts @@ -333,7 +333,7 @@ export class LargestFirstInputSelector implements IInputSelector { newInputs: selectedUtxos, newOutputs: new Set(), change: changeOutputs, - fee: MAX_U64, + fee: BigInt(1_000_000_000), }) ).fee; } diff --git a/packages/mesh-transaction/src/mesh-tx-builder/index.ts b/packages/mesh-transaction/src/mesh-tx-builder/index.ts index b6c6339f5..00850c976 100644 --- a/packages/mesh-transaction/src/mesh-tx-builder/index.ts +++ b/packages/mesh-transaction/src/mesh-tx-builder/index.ts @@ -234,7 +234,15 @@ export class MeshTxBuilder extends MeshTxBuilderCore { selectionSkeleton: TransactionPrototype, ): Promise => { const clonedBuilder = this.clone(); - await clonedBuilder.updateByTxPrototype(selectionSkeleton); + if (this.manualFee) { + const newSelectionSkeleton = { + ...selectionSkeleton, + fee: BigInt(this.manualFee), + }; + await clonedBuilder.updateByTxPrototype(newSelectionSkeleton); + } else { + await clonedBuilder.updateByTxPrototype(selectionSkeleton); + } try { await clonedBuilder.evaluateRedeemers();