diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b618fb70..96a2ebad5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,9 +20,20 @@ jobs: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} - name: Install dependencies run: npm install - name: Lint run: npm run lint - name: Build run: npm run build:mesh && npm run build:docs && npm run build:apps && npm run build:scripts + - name: Test Scalus integrations + run: | + npm test --workspace @meshsdk/core-cst + npm test --workspace @meshsdk/scalus-emulator + - name: Test Scalus package exports + run: | + npm run test:package --workspace @meshsdk/core-cst + npm run test:package --workspace @meshsdk/scalus-emulator + npm run test:scalus-packages diff --git a/package-lock.json b/package-lock.json index cabd2db05..b096c566e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.12", "dotenv": "^16.4.5", + "esbuild": "^0.28.1", "jest": "^29.7.0", "prettier": "^3.2.5", "ts-node": "^10.9.2", @@ -2716,9 +2717,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3128,9 +3126,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3145,9 +3140,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3162,9 +3154,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3179,9 +3168,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3196,9 +3182,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3213,9 +3196,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3230,9 +3210,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3247,9 +3224,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3264,9 +3238,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3281,9 +3252,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3298,9 +3266,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3315,9 +3280,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3332,9 +3294,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4365,9 +4324,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4382,9 +4338,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4399,9 +4352,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4416,9 +4366,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4433,9 +4380,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4450,9 +4394,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4467,9 +4408,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4484,9 +4422,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4501,9 +4436,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4518,9 +4450,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -13200,10 +13129,13 @@ } }, "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" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/scalus/-/scalus-1.2.1.tgz", + "integrity": "sha512-zpYcEpZUTgC4Jb2XFZ9XcULS04RmPpiYeYiac9+maOtHf1hCdLUQp26Y0Eoyhil6O4BtcS48fPqZffdS8pgIvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } }, "node_modules/semver": { "version": "7.8.5", @@ -15605,8 +15537,7 @@ "@meshsdk/core-cst": "1.9.1", "@meshsdk/provider": "1.9.0-beta.101", "@meshsdk/transaction": "1.9.1", - "@meshsdk/wallet": "1.9.1", - "scalus": "^0.17.0" + "@meshsdk/wallet": "1.9.1" }, "devDependencies": { "@meshsdk/configs": "*", @@ -15657,7 +15588,8 @@ "bech32": "^2.0.0", "blakejs": "^1.2.1", "bn.js": "^5.2.0", - "hash.js": "^1.1.7" + "hash.js": "^1.1.7", + "scalus": "^1.2.1" }, "devDependencies": { "@meshsdk/configs": "*", @@ -15676,11 +15608,12 @@ "dependencies": { "@meshsdk/common": "1.9.1", "@meshsdk/core-cst": "1.9.1", - "scalus": "^0.17.0" + "scalus": "^1.2.1" }, "devDependencies": { "@meshsdk/configs": "*", "eslint": "^8.57.0", + "ts-jest": "^29.1.4", "tsup": "^8.0.2", "typescript": "^5.3.3" } diff --git a/package.json b/package.json index d7178dc2a..a8aa5b35b 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,15 @@ "lint": "turbo lint", "pack": "turbo pack", "test": "turbo test -- --passWithNoTests", - "sh:version": "sh scripts/bump-version.sh" + "sh:version": "sh scripts/bump-version.sh", + "test:scalus-packages": "node scripts/test-scalus-packages.mjs" }, "devDependencies": { "@meshsdk/configs": "*", "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.12", "dotenv": "^16.4.5", + "esbuild": "^0.28.1", "jest": "^29.7.0", "prettier": "^3.2.5", "ts-node": "^10.9.2", diff --git a/packages/mesh-core-cst/README.md b/packages/mesh-core-cst/README.md index 7916a0719..2c4bebf06 100644 --- a/packages/mesh-core-cst/README.md +++ b/packages/mesh-core-cst/README.md @@ -3,3 +3,45 @@ Types and utilities functions between Mesh and [cardano-js-sdk](https://github.com/input-output-hk/cardano-js-sdk) [meshjs.dev](https://meshjs.dev/) + +## Scalus offline evaluator + +`OfflineEvaluatorScalus` declares Scalus as a runtime dependency and loads it +inside `evaluateTx` using native dynamic import. The ESM and CommonJS package +entries can both use the ESM-only Scalus package without embedding its bundle. + +The optional fifth constructor parameter selects the ledger protocol major +version. It defaults to 10 to preserve the prior version selection. Pass the +version used by the target ledger explicitly when evaluating other eras: + +```ts +const evaluator = new OfflineEvaluatorScalus( + fetcher, + "preprod", + undefined, // network slot configuration + undefined, // positional V1, V2, V3 cost models + 11, +); +``` + +Cost models are positional: indices 0, 1 and 2 are V1, V2 and V3. They must +match the target ledger's protocol parameters. Voting and proposing redeemers +map to Mesh's VOTE and PROPOSE tags. + +## Budget change on upgrade + +For the existing minting test, published Mesh with Scalus 0.17.0 reports +508,703 memory and 164,980,381 steps. Scalus 1.2.0 reports 508,703 memory and +164,973,765 steps at explicit PV10. This measured version delta +is separate from the protocol-default change; preserving PV10 does not promise +identical budgets across evaluator releases. The exact internal cause of this +delta has not been established by this integration work. + +Run the real evaluator test with: + +```sh +npm run test:scalus --workspace @meshsdk/core-cst +``` + +The dedicated Jest configuration enables ESM for the Scalus dependency. Scalus +1.2 requires Node.js 20 or newer and uses Web Crypto in browser bundles. diff --git a/packages/mesh-core-cst/jest.config.ts b/packages/mesh-core-cst/jest.config.ts index 9229656e9..1e7ddeecd 100644 --- a/packages/mesh-core-cst/jest.config.ts +++ b/packages/mesh-core-cst/jest.config.ts @@ -2,6 +2,7 @@ import type { Config } from "jest"; const jestConfig: Config = { clearMocks: true, + testPathIgnorePatterns: ["/offline-evaluator-scalus.test.ts$"], maxWorkers: 1, testEnvironment: "node", testMatch: ["**/packages/**/*.test.ts"], diff --git a/packages/mesh-core-cst/jest.scalus.config.ts b/packages/mesh-core-cst/jest.scalus.config.ts new file mode 100644 index 000000000..92e25277c --- /dev/null +++ b/packages/mesh-core-cst/jest.scalus.config.ts @@ -0,0 +1,10 @@ +import base from "./jest.config"; + +export default { + ...base, + testPathIgnorePatterns: [], + testMatch: ["**/offline-evaluator-scalus.test.ts"], + preset: "ts-jest/presets/default-esm", + extensionsToTreatAsEsm: [".ts"], + transform: { "^.+\\.[jt]s?$": ["ts-jest", { useESM: true }] }, +}; diff --git a/packages/mesh-core-cst/package.json b/packages/mesh-core-cst/package.json index 7c509852b..014c42d67 100644 --- a/packages/mesh-core-cst/package.json +++ b/packages/mesh-core-cst/package.json @@ -24,7 +24,9 @@ "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", "pack": "npm pack --pack-destination=./dist", - "test": "jest" + "test": "jest && npm run test:scalus", + "test:scalus": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --config jest.scalus.config.ts", + "test:package": "node --test test/scalus-package.test.mjs" }, "devDependencies": { "@meshsdk/configs": "*", @@ -50,7 +52,8 @@ "bech32": "^2.0.0", "blakejs": "^1.2.1", "bn.js": "^5.2.0", - "hash.js": "^1.1.7" + "hash.js": "^1.1.7", + "scalus": "^1.2.1" }, "overrides": { "@cardano-sdk/crypto": { diff --git a/packages/mesh-core-cst/src/offline-providers/offline-evaluator-scalus.ts b/packages/mesh-core-cst/src/offline-providers/offline-evaluator-scalus.ts index 5682a583e..90c5b5fb3 100644 --- a/packages/mesh-core-cst/src/offline-providers/offline-evaluator-scalus.ts +++ b/packages/mesh-core-cst/src/offline-providers/offline-evaluator-scalus.ts @@ -1,4 +1,6 @@ -import { Scalus, SlotConfig as ScalusSlotConfig } from "scalus"; +// Type-only: erased at compile time, so the CommonJS build still loads Scalus through the +// dynamic import() below rather than a top-level require of an ESM-only package. +import type { RedeemerBudget } from "scalus"; import { Action, @@ -21,6 +23,19 @@ import { getTransactionOutputs, } from "../utils/transaction"; +/** + * How Scalus spells each redeemer tag, and how mesh does. Keying this on Scalus's own union + * makes a tag added upstream a compile error here rather than a runtime throw. + */ +const MESH_TAG: Record = { + Spend: "SPEND", + Mint: "MINT", + Cert: "CERT", + Reward: "REWARD", + Voting: "VOTE", + Proposing: "PROPOSE", +}; + export class OfflineEvaluatorScalus implements IEvaluator { private readonly fetcher: IFetcher; private readonly network: Network; @@ -33,12 +48,16 @@ export class OfflineEvaluatorScalus implements IEvaluator { * @param network - The network to evaluate scripts for * @param slotConfig - Slot configuration for the network (optional, defaults to network-specific values) * @param customCostModels - Custom cost models for Plutus versions (optional, defaults to mainnet cost models) + * @param protocolMajorVersion Ledger protocol version used for costing. Defaults to 11 + * (van Rossem), the version mainnet runs. Pass 10 to reproduce budgets from before the + * van Rossem hard fork. */ constructor( fetcher: IFetcher, network: Network, slotConfig?: Omit, "epochLength">, customCostModels?: number[][], + public readonly protocolMajorVersion = 11, ) { this.fetcher = fetcher; this.network = network; @@ -120,7 +139,13 @@ export class OfflineEvaluatorScalus implements IEvaluator { ); } - return Scalus.evalPlutusScripts( + // Keep native import() in the CJS build: Scalus 1.x is ESM-only. + // + // `evalPlutusScripts` is the top-level export. The `Scalus` namespace object that used to + // carry it is a 0.x shape kept only for backwards compatibility, and is deprecated. + const { evalPlutusScripts, SlotConfig: ScalusSlotConfig } = + await import("scalus"); + return evalPlutusScripts( Buffer.from(tx, "hex"), cborWriter.encode(), new ScalusSlotConfig( @@ -129,9 +154,14 @@ export class OfflineEvaluatorScalus implements IEvaluator { this.slotConfig.slotLength, ), this.costModels, - ).map((scalusRedeemer: Scalus.Redeemer) => { + this.protocolMajorVersion, + ).map((scalusRedeemer) => { + // Still guarded at runtime: the dependency is a caret range, so an installed Scalus can + // emit a tag the compiled-against declarations did not carry. + const tag = MESH_TAG[scalusRedeemer.tag]; + if (!tag) throw new Error(`Unknown redeemer tag: ${scalusRedeemer.tag}`); return { - tag: scalusRedeemer.tag.toUpperCase() as RedeemerTagType, + tag, index: scalusRedeemer.index, budget: { mem: Number(scalusRedeemer.budget.memory), diff --git a/packages/mesh-core-cst/src/types/cardano-sdk.ts b/packages/mesh-core-cst/src/types/cardano-sdk.ts index 2b47261eb..2877d2590 100644 --- a/packages/mesh-core-cst/src/types/cardano-sdk.ts +++ b/packages/mesh-core-cst/src/types/cardano-sdk.ts @@ -221,6 +221,8 @@ export type CostModel = Serialization.CostModel; export const CborWriter = Serialization.CborWriter; export type CborWriter = Serialization.CborWriter; +export const CborReader = Serialization.CborReader; +export type CborReader = Serialization.CborReader; export const ConstrPlutusData = Serialization.ConstrPlutusData; export type ConstrPlutusData = Serialization.ConstrPlutusData; diff --git a/packages/mesh-core-cst/src/utils/index.ts b/packages/mesh-core-cst/src/utils/index.ts index 31a1adab7..e80fa56e3 100644 --- a/packages/mesh-core-cst/src/utils/index.ts +++ b/packages/mesh-core-cst/src/utils/index.ts @@ -8,3 +8,4 @@ export * from "./encoding"; export * from "./witness-set"; export * from "./fee"; export * from "./proposal"; +export * from "./transaction"; diff --git a/packages/mesh-core-cst/src/utils/transaction.ts b/packages/mesh-core-cst/src/utils/transaction.ts index da11c4bf3..ba952a656 100644 --- a/packages/mesh-core-cst/src/utils/transaction.ts +++ b/packages/mesh-core-cst/src/utils/transaction.ts @@ -59,7 +59,6 @@ export const getTransactionOutputs = (txHex: string): UTxO[] => { deserializedTx .body() .outputs() - .values() .forEach((output, index) => { outputs.push( fromTxUnspentOutput( diff --git a/packages/mesh-core-cst/test/fixtures/scalus-v3.mjs b/packages/mesh-core-cst/test/fixtures/scalus-v3.mjs new file mode 100644 index 000000000..57d18a6d9 --- /dev/null +++ b/packages/mesh-core-cst/test/fixtures/scalus-v3.mjs @@ -0,0 +1,19 @@ +// Context-reading Plutus V3 withdrawal fixture from Scalus provider tests. +export const scriptTxCborHex = + "84a600d90102818258200000000000000000000000000000000000000000000000000000" + + "00000000000000018182581d61c8c47610a36034aac6fc58848bdae5c278d994ff502c05" + + "455e3b3ee81b000000012a033c4b021a0002b5b505a1581df10bfe55212716fedb6bdad1" + + "0f6f873a49ea78517c3b708351ef6da2e0000b582054b738fa0af504223504d8dc4d6cec" + + "d83a711e90bc8185052dc6dc07bee4a9450dd90102818258200000000000000000000000" + + "00000000000000000000000000000000000000000000a300d90102818258206ea31d27d5" + + "85439ea8fd9cd8e6664ed83e605c06aec24d32dfaba488e49287d95840d1521aadba8b80" + + "0330d42c96134a1c51d5c43eb23d3d685bf4874a630ba12a1670100aa848f45eaa4cf5e4" + + "3aea782c0574f9773590ffb89ab520c4fe8423cb0a05a1820300820582197e3e1a008596" + + "d207d9010281587358710101002323233001001337006eb4d5d09aba2002337006466600" + + "20026eb0d5d09aab9e37546ae8400d2000222533357346ae8c00840044ccc00c00cd5d10" + + "0119b8000148008d55ce9baa357426ae88d5d1001112999ab9a3371200290000a4c26600" + + "400466e04005200235573c6ea80041f5f6"; +export const scriptUtxoCborHex = + "a18258200000000000000000000000000000000000000000000000000000000000000000" + + "0082581d61c8c47610a36034aac6fc58848bdae5c278d994ff502c05455e3b3ee81b0000" + + "00012a05f200"; diff --git a/packages/mesh-core-cst/test/offline-evaluator-scalus.test.ts b/packages/mesh-core-cst/test/offline-evaluator-scalus.test.ts index 439371727..a1de225c6 100644 --- a/packages/mesh-core-cst/test/offline-evaluator-scalus.test.ts +++ b/packages/mesh-core-cst/test/offline-evaluator-scalus.test.ts @@ -119,6 +119,7 @@ describe("OfflineEvaluatorScalus", () => { }; fetcher.addUTxOs([utxo_1, utxo_2, utxo_3, utxo_4, utxo_5, utxo_6, utxo_7]); + expect(evaluator.protocolMajorVersion).toBe(11); const res = await evaluator.evaluateTx(txHex, [], []); expect(res).toStrictEqual([ { @@ -126,7 +127,13 @@ describe("OfflineEvaluatorScalus", () => { tag: "MINT", budget: { mem: 508703, - steps: 164980381, + // Scalus 1.2.0 at the default PV11; 0.17.0 returned 164980381. + // + // This figure does not move with the protocol version: the cost models are supplied + // explicitly above and this script uses no version-gated builtin, so PV1 through PV99 + // all evaluate to the same budget (measured). It pins evaluation, not PV selection - + // the assertion that pins the PV default is `protocolMajorVersion` above. + steps: 164973765, }, }, ]); diff --git a/packages/mesh-core-cst/test/scalus-package.test.mjs b/packages/mesh-core-cst/test/scalus-package.test.mjs new file mode 100644 index 000000000..45bca585f --- /dev/null +++ b/packages/mesh-core-cst/test/scalus-package.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { test } from "node:test"; + +import { scriptTxCborHex, scriptUtxoCborHex } from "./fixtures/scalus-v3.mjs"; + +const require = createRequire(import.meta.url); +for (const format of ["esm", "cjs"]) { + test(`Scalus evaluator in the ${format} package evaluates a real V3 redeemer`, async () => { + const pkg = + format === "esm" + ? await import("../dist/index.js") + : require("../dist/index.cjs"); + assert.equal(scriptUtxoCborHex.slice(0, 2), "a1"); + const utxo = pkg.fromTxUnspentOutput( + pkg.TransactionUnspentOutput.fromCbor("82" + scriptUtxoCborHex.slice(2)), + ); + const evaluator = new pkg.OfflineEvaluatorScalus( + { fetchUTxOs: async () => [utxo] }, + "preview", + ); + const budgets = await evaluator.evaluateTx(scriptTxCborHex, [utxo]); + assert.deepEqual(budgets, [ + { tag: "REWARD", index: 0, budget: { mem: 32318, steps: 8754898 } }, + ]); + }); +} diff --git a/packages/mesh-core/package.json b/packages/mesh-core/package.json index 64eeb5b5a..8d4a3fa38 100644 --- a/packages/mesh-core/package.json +++ b/packages/mesh-core/package.json @@ -37,8 +37,7 @@ "@meshsdk/core-cst": "1.9.1", "@meshsdk/provider": "1.9.0-beta.101", "@meshsdk/transaction": "1.9.1", - "@meshsdk/wallet": "1.9.1", - "scalus": "^0.17.0" + "@meshsdk/wallet": "1.9.1" }, "prettier": "@meshsdk/configs/prettier", "publishConfig": { diff --git a/packages/mesh-scalus-emulator/README.md b/packages/mesh-scalus-emulator/README.md index a232ad9f2..6f6075003 100644 --- a/packages/mesh-scalus-emulator/README.md +++ b/packages/mesh-scalus-emulator/README.md @@ -1,5 +1,81 @@ -# mesh-scalus-emulator +# Scalus emulator for Mesh -Scalus emulator utilities for Mesh SDK. +A local ledger implementing Mesh's `IFetcher`, `ISubmitter` and `IEvaluator`. +Requires Scalus 1.2.0 or newer. The provider reads protocol parameters, cost +models and decoded UTxOs from the same emulator that validates transactions. -[meshjs.dev](https://meshjs.dev/) +```ts +import { MeshTxBuilder } from "@meshsdk/core"; +import { ScalusEmulator } from "@meshsdk/scalus-emulator"; + +// initialUtxos is a Mesh UTxO[]. The default network is preview. +const provider = await ScalusEmulator.create(initialUtxos); +const params = await provider.fetchProtocolParameters(); +const builder = new MeshTxBuilder({ + fetcher: provider, + submitter: provider, + evaluator: provider, + params, +}); +``` + +For an existing emulator, construction remains synchronous: + +```ts +import { CardanoInfo, Emulator } from "scalus"; + +import { ScalusEmulator } from "@meshsdk/scalus-emulator"; + +const emulator = Emulator.create(CardanoInfo.preprod()); +const provider = new ScalusEmulator(emulator); +``` + +## Migrating from the old constructor + +Replace `new ScalusEmulator(utxos, slotConfig, options)` with +`await ScalusEmulator.create(utxos, cardanoInfo)`. Use `CardanoInfo.preview()`, +`preprod()` or `mainnet()` for a standard network. For a private network, use +`CardanoInfo.custom(network, slotConfig, protocolParams)`. Custom protocol +parameters and cost models belong in that `CardanoInfo`. Pass the result of +`provider.fetchProtocolParameters()` as the builder's `params` option, as in the +example above, so building, evaluation and ledger validation use the same +configuration. The old separate +`protocolParams` and `costModels` options are removed. + +`provider.emulator` exposes the underlying ledger for seeding, snapshots, +slot control and assertions. Initial UTxOs preserve inline datums, datum +hashes and reference scripts. Following Mesh's UTxO convention, `plutusData` +means the datum is inline; a supplied `dataHash` must match it. A resolved +hash-datum output must carry `dataHash` only. Mesh reference scripts use their +raw script CBOR and the adapter handles the ledger's tag-24 wrapper. + +Explorer/history methods remain unsupported and reject with an explicit error. +`fetchProtocolParameters` reports the emulator's transaction-building parameters; +Mesh defaults supply block-level fields that Scalus's public API does not expose. + +## ESM and CommonJS + +Both package formats load Scalus lazily with native `import()`. CommonJS callers +can use `require("@meshsdk/scalus-emulator")`, then await `ScalusEmulator.create()`. +Do not use `require("scalus")`: Scalus is ESM-only. Scalus remains a direct runtime +dependency. Node.js 20 or newer is required by Scalus 1.2. + +## Browser builds + +Scalus 1.2 uses Web Crypto and can be included in browser bundles. The package +checks cover Node ESM, CommonJS and a headless-browser runtime. A browser bundle +of the combined Mesh dependency graph still needs the standard Buffer, crypto, +stream and events shims used by the package harness. + +## Tests + +After building workspace dependencies: + +```sh +npm test --workspace @meshsdk/scalus-emulator +SCALUS_TARBALL=/path/to/scalus-1.2.0.tgz npm run test:scalus-packages +``` + +The tests build, evaluate, sign and submit a context-reading Plutus V3 spend, +exercise payment/minting/rejection flows and verify parameter and UTxO conversion. +Jest runs with VM modules enabled so it tests the actual ESM dependency. diff --git a/packages/mesh-scalus-emulator/jest.config.ts b/packages/mesh-scalus-emulator/jest.config.ts index e8877e345..7971ec049 100644 --- a/packages/mesh-scalus-emulator/jest.config.ts +++ b/packages/mesh-scalus-emulator/jest.config.ts @@ -6,12 +6,14 @@ const jestConfig: Config = { testEnvironment: "node", testMatch: ["**/*.test.ts"], setupFiles: ["dotenv/config"], - preset: "ts-jest", + preset: "ts-jest/presets/default-esm", + extensionsToTreatAsEsm: [".ts"], moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1", + "^@meshsdk/core-cst$": "/../mesh-core-cst/src", }, transform: { - "^.+\\.[jt]s?$": "ts-jest", + "^.+\\.[jt]s?$": ["ts-jest", { useESM: true, diagnostics: false }], }, transformIgnorePatterns: ["/node_modules/(?!@meshsdk/.*)"], passWithNoTests: true, diff --git a/packages/mesh-scalus-emulator/package.json b/packages/mesh-scalus-emulator/package.json index c9a251024..a3dc49dbd 100644 --- a/packages/mesh-scalus-emulator/package.json +++ b/packages/mesh-scalus-emulator/package.json @@ -24,16 +24,18 @@ "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", "pack": "npm pack --pack-destination=./dist", - "test": "jest" + "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js", + "test:package": "node --test test/package.test.mjs" }, "dependencies": { - "scalus": "^0.17.0", + "@meshsdk/common": "1.9.1", "@meshsdk/core-cst": "1.9.1", - "@meshsdk/common": "1.9.1" + "scalus": "^1.2.1" }, "devDependencies": { "@meshsdk/configs": "*", "eslint": "^8.57.0", + "ts-jest": "^29.1.4", "tsup": "^8.0.2", "typescript": "^5.3.3" }, diff --git a/packages/mesh-scalus-emulator/src/index.ts b/packages/mesh-scalus-emulator/src/index.ts index 8556d6e75..5caa9ca23 100644 --- a/packages/mesh-scalus-emulator/src/index.ts +++ b/packages/mesh-scalus-emulator/src/index.ts @@ -1,12 +1,8 @@ -// Use `import type` for scalus types, `require()` at runtime since scalus is CJS -import { bech32 } from "@scure/base"; -import cbor from "cbor"; -import { Emulator, Scalus, SlotConfig, SubmitResult } from "scalus"; +import type { CardanoInfo, Emulator, RedeemerBudget, Utxo } from "scalus"; import type { AccountInfo, Action, - Asset, AssetMetadata, BlockInfo, GovernanceProposalInfo, @@ -14,116 +10,211 @@ import type { IFetcher, IFetcherOptions, ISubmitter, - SlotConfig as MeshSlotConfig, + Asset as MeshAsset, + UTxO as MeshUTxO, Protocol, + RedeemerTagType, TransactionInfo, - UTxO, } from "@meshsdk/common"; +import { castProtocol } 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"; + CborReader, + CborWriter, + getTransactionOutputs, + resolveDataHash, +} from "@meshsdk/core-cst"; + +function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) + throw new Error("Invalid hexadecimal CBOR"); + return Uint8Array.from(hex.match(/../g) ?? [], (byte) => parseInt(byte, 16)); +} -// Scalus is CJS so we use dynamic import at construction time -let ScalusLib: typeof import("scalus") | undefined; +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function toScalusScriptRef(scriptRef: string): Uint8Array { + const writer = new CborWriter(); + writer.writeTag(24); + writer.writeByteString(hexToBytes(scriptRef)); + return writer.encode(); +} + +function toMeshScriptRef(scriptRef: Uint8Array): string { + const reader = new CborReader(scriptRef); + if (Number(reader.readTag()) !== 24) + throw new Error("Invalid Scalus reference script: expected CBOR tag 24"); + return bytesToHex(reader.readByteString()); +} /** - * 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 }); - * ``` + * `"Spend"` and friends, as mesh spells them. Keying this on Scalus's own union makes a tag + * added upstream a compile error here rather than a runtime throw. */ -export class ScalusEmulator implements IFetcher, ISubmitter, IEvaluator { - public emulator: Emulator; - private slotConfig: SlotConfig; - private protocolParams: Protocol; - private costModels: number[][]; - - constructor( - initialUtxos: UTxO[], - slotConfig: MeshSlotConfig, - options?: { - protocolParams?: Protocol; - costModels?: { - PlutusV1?: number[]; - PlutusV2?: number[]; - PlutusV3?: number[]; - }; +const MESH_TAG: Record = { + Spend: "SPEND", + Mint: "MINT", + Cert: "CERT", + Reward: "REWARD", + Voting: "VOTE", + Proposing: "PROPOSE", +}; + +function toMeshUtxo(utxo: Utxo): MeshUTxO { + const amount: MeshAsset[] = [ + { unit: "lovelace", quantity: utxo.value.coin.toString() }, + ...utxo.value.assets.map((a) => ({ + unit: a.unit, + quantity: a.quantity.toString(), + })), + ]; + return { + input: { txHash: utxo.txHash, outputIndex: utxo.outputIndex }, + output: { + address: utxo.address, + amount, + dataHash: utxo.datumHash, + plutusData: utxo.inlineDatum && bytesToHex(utxo.inlineDatum), + scriptRef: utxo.scriptRef && toMeshScriptRef(utxo.scriptRef), }, - ) { - const scalusSlotConfig = new SlotConfig( - slotConfig.zeroTime, - slotConfig.zeroSlot, - slotConfig.slotLength, - ); - this.emulator = new Emulator( - Buffer.from(utxosToCborMap(initialUtxos), "hex"), - scalusSlotConfig, + }; +} + +function fromMeshUtxo(utxo: MeshUTxO, lib: typeof import("scalus")): Utxo { + const { Asset, Utxo, Value } = lib; + const lovelace = utxo.output.amount.find((a) => a.unit === "lovelace"); + const assets = utxo.output.amount + .filter((a) => a.unit !== "lovelace") + .map( + (a) => + new Asset(a.unit.slice(0, 56), a.unit.slice(56), BigInt(a.quantity)), ); - this.slotConfig = scalusSlotConfig; - 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, - ]; + const value = new Value(BigInt(lovelace?.quantity ?? "0"), assets); + const { dataHash, plutusData, scriptRef } = utxo.output; + if ( + dataHash && + plutusData && + dataHash !== resolveDataHash(plutusData, "CBOR") + ) + throw new Error("a UTxO's datum hash does not match its inline datum"); + const base = new Utxo( + utxo.input.txHash, + utxo.input.outputIndex, + utxo.output.address, + value, + ); + // Neither the datum nor the reference script may be dropped here. An inline datum is how a + // script UTxO carries its state, so an input that reaches `evaluateTx` without it builds a + // script context that is missing it - a wrong budget, or a phase-2 failure that reads like a + // validator bug. + const withDatum = plutusData + ? base.withInlineDatum(hexToBytes(plutusData)) + : dataHash + ? base.withDatumHash(dataHash) + : base; + return scriptRef + ? withDatum.withScriptRef(toScalusScriptRef(scriptRef)) + : withDatum; +} - // Eagerly load the scalus module - if (!ScalusLib) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - ScalusLib = require("scalus") as typeof import("scalus"); - } +export class ScalusEmulator implements IFetcher, ISubmitter, IEvaluator { + constructor(readonly emulator: Emulator) {} + + /** Create a provider without synchronously loading the ESM-only Scalus package. */ + static async create( + initialUtxos: MeshUTxO[] = [], + info?: CardanoInfo, + ): Promise { + const lib = await import("scalus"); + const emulator = lib.Emulator.create(info ?? lib.CardanoInfo.preview()); + for (const utxo of initialUtxos) emulator.addUtxo(fromMeshUtxo(utxo, lib)); + return new ScalusEmulator(emulator); } - // --------------------------------------------------------------------------- - // IFetcher - // --------------------------------------------------------------------------- + async setSlot(slot: number): Promise { + this.emulator.setSlot(slot); + } - 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 fetchProtocolParameters(epoch = 0): Promise { + const p = this.emulator.getProtocolParameters(); + // `castProtocol` fills in the parameters a transaction build never reads (block sizes, + // decentralisation, min pool cost) from mesh's own defaults. + return castProtocol({ + epoch, + minFeeA: p.txFeePerByte, + minFeeB: p.txFeeFixed, + maxTxSize: p.maxTxSize, + maxValSize: p.maxValueSize, + keyDeposit: p.stakeAddressDeposit.toString(), + poolDeposit: p.stakePoolDeposit.toString(), + coinsPerUtxoSize: Number(p.utxoCostPerByte), + priceMem: p.priceMemory, + priceStep: p.priceSteps, + maxTxExMem: p.maxTxExecutionMemory.toString(), + maxTxExSteps: p.maxTxExecutionSteps.toString(), + collateralPercent: p.collateralPercentage, + maxCollateralInputs: p.maxCollateralInputs, + minFeeRefScriptCostPerByte: p.minFeeRefScriptCostPerByte, + }); } - 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 fetchAddressUTxOs( + address: string, + asset?: string, + ): Promise { + const filter = asset === undefined ? { address } : { address, unit: asset }; + return this.emulator.getUtxos(filter).map(toMeshUtxo); } - async fetchProtocolParameters(_epoch: number): Promise { - return this.protocolParams; + async fetchUTxOs(hash: string, index?: number): Promise { + const utxos = this.emulator.getUtxos({ txHash: hash }); + const matching = + index === undefined + ? utxos + : utxos.filter((u) => u.outputIndex === index); + return matching.map(toMeshUtxo); } - async fetchCostModels(_epoch: number): Promise { - return this.costModels; + async submitTx(txHex: string): Promise { + const result = this.emulator.submitTx(hexToBytes(txHex)); + if (!result.isSuccess) { + throw new Error( + `${result.errorRule}: ${result.error} ${result.logs.join(" ")}`, + ); + } + return result.txHash!; + } + + async evaluateTx( + txHex: string, + additionalUtxos: MeshUTxO[] = [], + additionalTxs: string[] = [], + ): Promise[]> { + const lib = await import("scalus"); + const pendingOutputs = additionalTxs.flatMap(getTransactionOutputs); + const budgets = this.emulator.evaluateTx( + hexToBytes(txHex), + [...additionalUtxos, ...pendingOutputs].map((u) => fromMeshUtxo(u, lib)), + ); + return budgets.map((r) => { + const tag = MESH_TAG[r.tag]; + if (!tag) throw new Error(`Unknown redeemer tag: ${r.tag}`); + return { + tag, + index: r.index, + budget: { mem: Number(r.budget.memory), steps: Number(r.budget.steps) }, + }; + }); } - // --- Unsupported IFetcher methods (emulator doesn't track this data) --- + async fetchCostModels(_epoch?: number): Promise { + const { PlutusV1, PlutusV2, PlutusV3 } = + this.emulator.getProtocolParameters().costModels; + return [PlutusV1, PlutusV2, PlutusV3]; + } + // Explorer/history queries are outside the emulator provider surface. async fetchAccountInfo(_address: string): Promise { throw new Error("fetchAccountInfo not supported by ScalusEmulator"); } @@ -152,7 +243,7 @@ export class ScalusEmulator implements IFetcher, ISubmitter, IEvaluator { async fetchCollectionAssets( _policyId: string, _cursor?: number | string, - ): Promise<{ assets: Asset[]; next?: string | number | null }> { + ): Promise<{ assets: MeshAsset[]; next?: string | number | null }> { throw new Error("fetchCollectionAssets not supported by ScalusEmulator"); } @@ -170,212 +261,4 @@ export class ScalusEmulator implements IFetcher, ISubmitter, IEvaluator { async get(_url: string): Promise { throw new Error("get not supported by ScalusEmulator"); } - - async setSlot(slot: number): Promise { - this.emulator.setSlot(slot); - } - - // --------------------------------------------------------------------------- - // 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-scalus-emulator/test/index.test.ts b/packages/mesh-scalus-emulator/test/index.test.ts index e4d795ee4..41613cf17 100644 --- a/packages/mesh-scalus-emulator/test/index.test.ts +++ b/packages/mesh-scalus-emulator/test/index.test.ts @@ -1,17 +1,20 @@ -import { SlotConfig } from "scalus"; +import { CardanoInfo, ProtocolParams, Utxo, Value } from "scalus"; import { applyCborEncoding, MeshTxBuilder, NativeScript, + resolveDataHash, resolveNativeScriptHash, resolveNativeScriptHex, resolvePaymentKeyHash, + resolvePlutusScriptAddress, resolveScriptHash, SLOT_CONFIG_NETWORK, unixTimeToEnclosingSlot, UTxO, } from "@meshsdk/core"; +import { getTransactionOutputs } from "@meshsdk/core-cst"; import { MeshWallet } from "@meshsdk/wallet"; import { ScalusEmulator } from "../src"; @@ -43,6 +46,13 @@ const TEST_MNEMONIC = [ "solution", ]; +// Context-reading Plutus V3 validator from Scalus provider conformance fixtures. +const contextSensitiveScriptHex = + "58710101002323233001001337006eb4d5d09aba200233700646660020026eb0d5d0" + + "9aab9e37546ae8400d2000222533357346ae8c00840044ccc00c00cd5d100119b800" + + "0148008d55ce9baa357426ae88d5d1001112999ab9a3371200290000a4c266004004" + + "66e04005200235573c6ea80041"; + const alwaysSucceedCbor = applyCborEncoding( "58340101002332259800a518a4d153300249011856616c696461746f722072657475726e65642066616c736500136564004ae715cd01", ); @@ -55,7 +65,7 @@ async function createTestSetup(lovelacePerAddress = 10_000_000_000n) { await wallet.init(); const address = (await wallet.getChangeAddress())!; - const provider = new ScalusEmulator( + const provider = await ScalusEmulator.create( [ { input: { @@ -71,17 +81,19 @@ async function createTestSetup(lovelacePerAddress = 10_000_000_000n) { }, }, ], - SLOT_CONFIG_NETWORK["preview"], + CardanoInfo.preview(), ); await provider.setSlot( unixTimeToEnclosingSlot(Date.now(), SLOT_CONFIG_NETWORK["preview"]), ); + const params = await provider.fetchProtocolParameters(); const newTxBuilder = () => new MeshTxBuilder({ fetcher: provider, submitter: provider, evaluator: provider, + params, }); return { @@ -339,7 +351,7 @@ describe("ScalusEmulator", () => { ).toBe("100"); }); - it("should evaluate a plutus spending transaction", async () => { + it("should query outputs after a plutus minting transaction", async () => { const { wallet, address, provider, newTxBuilder, emulator } = await createTestSetup(); @@ -382,6 +394,215 @@ describe("ScalusEmulator", () => { }); }); + it("builds, evaluates, signs and submits a context-reading Plutus V3 spend", async () => { + const { wallet, address, provider, newTxBuilder, emulator } = + await createTestSetup(); + const scriptAddress = resolvePlutusScriptAddress( + { code: applyCborEncoding(contextSensitiveScriptHex), version: "V3" }, + 0, + ); + emulator.addUtxo( + new Utxo( + "11".repeat(32), + 0, + scriptAddress, + Value.ada(50n), + ).withInlineDatum(Uint8Array.of(0x18, 0x2a)), + ); + emulator.addUtxo(new Utxo("22".repeat(32), 0, address, Value.ada(10n))); + const [scriptUtxo] = await provider.fetchAddressUTxOs(scriptAddress); + expect(scriptUtxo!.output.plutusData).toBe("182a"); + const walletUtxos = await provider.fetchAddressUTxOs(address); + const collateral = walletUtxos.find( + (u) => u.input.txHash === "22".repeat(32), + )!; + const tx = await newTxBuilder() + .spendingPlutusScriptV3() + .txIn( + scriptUtxo!.input.txHash, + scriptUtxo!.input.outputIndex, + scriptUtxo!.output.amount, + scriptAddress, + ) + .txInInlineDatumPresent() + .txInRedeemerValue("05", "CBOR") + .txInScript(applyCborEncoding(contextSensitiveScriptHex)) + .txInCollateral( + collateral.input.txHash, + collateral.input.outputIndex, + collateral.output.amount, + address, + ) + .changeAddress(address) + .selectUtxosFrom(walletUtxos.filter((u) => u !== collateral)) + .complete(); + const budgets = await provider.evaluateTx(tx); + expect(budgets).toHaveLength(1); + expect(budgets[0]!.tag).toBe("SPEND"); + expect(budgets[0]!.budget.steps).toBeGreaterThan(0); + const signed = await wallet.signTx(tx); + const hash = await provider.submitTx(signed); + expect(emulator.hasTx(hash)).toBe(true); + expect(await provider.fetchAddressUTxOs(scriptAddress)).toEqual([]); + }); + + it("uses the emulator's custom parameters and keeps cost models in V1/V2/V3 order", async () => { + const info = CardanoInfo.preview(); + const json = JSON.parse(info.protocolParams.toBlockfrostJson()); + json.min_fee_a = 2000; + const custom = info.withProtocolParams( + ProtocolParams.fromBlockfrostJson(JSON.stringify(json)), + ); + const provider = await ScalusEmulator.create([], custom); + const params = await provider.fetchProtocolParameters(); + expect(params.minFeeA).toBe(2000); + const models = custom.protocolParams.costModels; + expect(await provider.fetchCostModels()).toEqual([ + models.PlutusV1, + models.PlutusV2, + models.PlutusV3, + ]); + + const { wallet, address } = await createTestSetup(); + provider.emulator.addUtxo( + new Utxo("44".repeat(32), 0, address, Value.ada(20n)), + ); + const inputs = await provider.fetchAddressUTxOs(address); + const underpriced = await new MeshTxBuilder({ + fetcher: provider, + submitter: provider, + evaluator: provider, + }) + .txOut(address, [{ unit: "lovelace", quantity: "5000000" }]) + .changeAddress(address) + .selectUtxosFrom(inputs) + .complete(); + await expect( + provider.submitTx(await wallet.signTx(underpriced)), + ).rejects.toThrow("less than minimum required"); + const tx = await new MeshTxBuilder({ + fetcher: provider, + submitter: provider, + evaluator: provider, + params, + }) + .txOut(address, [{ unit: "lovelace", quantity: "5000000" }]) + .changeAddress(address) + .selectUtxosFrom(inputs) + .complete(); + const hash = await provider.submitTx(await wallet.signTx(tx)); + expect(provider.emulator.hasTx(hash)).toBe(true); + }); + + it("evaluates a script spend from a pending parent transaction", async () => { + const { address, provider, emulator, newTxBuilder } = + await createTestSetup(); + const scriptAddress = resolvePlutusScriptAddress( + { code: applyCborEncoding(contextSensitiveScriptHex), version: "V3" }, + 0, + ); + const parent = await newTxBuilder() + .txOut(scriptAddress, [{ unit: "lovelace", quantity: "50000000" }]) + .txOutInlineDatumValue("182a", "CBOR") + .changeAddress(address) + .selectUtxosFrom(await provider.fetchAddressUTxOs(address)) + .complete(); + const scriptUtxo = getTransactionOutputs(parent).find( + (u) => u.output.address === scriptAddress, + )!; + const chainedFetcher = Object.create(provider) as ScalusEmulator; + chainedFetcher.fetchUTxOs = async (hash, index) => + hash === scriptUtxo.input.txHash && + (index === undefined || index === scriptUtxo.input.outputIndex) + ? [scriptUtxo] + : provider.fetchUTxOs(hash, index); + const child = await new MeshTxBuilder({ + fetcher: chainedFetcher, + submitter: provider, + evaluator: provider, + params: await provider.fetchProtocolParameters(), + }) + .spendingPlutusScriptV3() + .txIn( + scriptUtxo.input.txHash, + scriptUtxo.input.outputIndex, + scriptUtxo.output.amount, + scriptAddress, + ) + .txInInlineDatumPresent() + .txInRedeemerValue("05", "CBOR") + .txInScript(applyCborEncoding(contextSensitiveScriptHex)) + .inputForEvaluation(scriptUtxo) + .txOut(address, [{ unit: "lovelace", quantity: "40000000" }]) + .changeAddress(address) + .chainTx(parent) + .complete(); + + expect(emulator.hasTx(scriptUtxo.input.txHash)).toBe(false); + await expect(provider.evaluateTx(child)).rejects.toThrow(); + const budgets = await provider.evaluateTx(child, [], [parent]); + expect(budgets).toHaveLength(1); + expect(budgets[0]!.tag).toBe("SPEND"); + }); + + it("preserves datum hashes, inline datums and reference scripts in initial UTxOs", async () => { + const { address } = await createTestSetup(); + const base = { + input: { txHash: "33".repeat(32), outputIndex: 0 }, + output: { address, amount: [{ unit: "lovelace", quantity: "5000000" }] }, + }; + const inputs = [ + { + ...base, + output: { + ...base.output, + plutusData: "182a", + scriptRef: "82034100", + }, + }, + { + ...base, + input: { ...base.input, outputIndex: 1 }, + output: { ...base.output, dataHash: "ab".repeat(32) }, + }, + ]; + const provider = await ScalusEmulator.create(inputs); + for (const input of inputs) + expect( + ( + await provider.fetchUTxOs(input.input.txHash, input.input.outputIndex) + )[0], + ).toMatchObject(input); + const inlineDatum = "182a"; + await expect( + ScalusEmulator.create([ + { + ...base, + output: { + ...base.output, + plutusData: inlineDatum, + dataHash: resolveDataHash(inlineDatum, "CBOR"), + }, + }, + ]), + ).resolves.toBeInstanceOf(ScalusEmulator); + await expect( + ScalusEmulator.create([ + { + ...base, + output: { + ...base.output, + plutusData: inlineDatum, + dataHash: "ab".repeat(32), + }, + }, + ]), + ).rejects.toThrow("does not match"); + await expect(provider.evaluateTx("zz")).rejects.toThrow( + "Invalid hexadecimal CBOR", + ); + }); + describe("Validity intervals", () => { it("should build and submit a transaction with TTL", async () => { const { wallet, address, provider, newTxBuilder, slotConfig } = diff --git a/packages/mesh-scalus-emulator/test/package.test.mjs b/packages/mesh-scalus-emulator/test/package.test.mjs new file mode 100644 index 000000000..195280a6f --- /dev/null +++ b/packages/mesh-scalus-emulator/test/package.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { test } from "node:test"; + +const require = createRequire(import.meta.url); +for (const format of ["esm", "cjs"]) { + test(`the ${format} provider package initializes and queries Scalus`, async () => { + const pkg = + format === "esm" + ? await import("../dist/index.js") + : require("../dist/index.cjs"); + const provider = await pkg.ScalusEmulator.create(); + assert.ok((await provider.fetchProtocolParameters()).minFeeA > 0); + assert.equal((await provider.fetchCostModels()).length, 3); + assert.deepEqual(await provider.fetchUTxOs("00".repeat(32)), []); + await assert.rejects(provider.submitTx("80")); + }); +} diff --git a/scripts/test-scalus-packages.mjs b/scripts/test-scalus-packages.mjs new file mode 100644 index 000000000..9b6301dc1 --- /dev/null +++ b/scripts/test-scalus-packages.mjs @@ -0,0 +1,428 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { constants } from "node:fs"; +import { + access, + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const fixture = join( + repo, + "packages/mesh-core-cst/test/fixtures/scalus-v3.mjs", +); +const packages = ["mesh-common", "mesh-core-cst", "mesh-scalus-emulator"]; + +function run(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => (stdout += chunk)); + child.stderr?.on("data", (chunk) => (stderr += chunk)); + child.on("error", reject); + child.on("close", (code, signal) => { + if (code === 0) return resolvePromise({ stdout, stderr }); + reject( + new Error( + `${command} ${args.join(" ")} failed (${signal ?? code})\n${stdout}${stderr}`, + ), + ); + }); + }); +} + +function runChrome(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + let stdout = ""; + let stderr = ""; + let result; + let killTimer; + const timeout = setTimeout(() => { + if (result) return; + result = new Error( + `Chrome timed out after 45 seconds\n${stdout}\n${stderr}`, + ); + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), 2000); + }, 45_000); + const finishFromDom = () => { + if (result) return; + if (stdout.includes("SCALUS_BROWSER_TEST_PASSED")) { + result = { stdout, stderr }; + clearTimeout(timeout); + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), 2000); + } else if (stdout.includes("SCALUS_BROWSER_TEST_FAILED")) { + result = new Error(`browser runtime failed\n${stdout}\n${stderr}`); + clearTimeout(timeout); + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), 2000); + } + }; + child.stdout.on("data", (chunk) => { + stdout += chunk; + finishFromDom(); + }); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("error", (error) => { + clearTimeout(timeout); + clearTimeout(killTimer); + reject(error); + }); + child.on("close", (code, signal) => { + clearTimeout(timeout); + clearTimeout(killTimer); + if (!result && code === 0) result = { stdout, stderr }; + if (!result) + result = new Error( + `Chrome failed (${signal ?? code})\n${stdout}\n${stderr}`, + ); + result instanceof Error ? reject(result) : resolvePromise(result); + }); + }); +} + +async function pack(packageName, packDir) { + const packageDir = join(repo, "packages", packageName); + await access(join(packageDir, "dist", "index.js"), constants.R_OK); + await access(join(packageDir, "dist", "index.cjs"), constants.R_OK); + await access(join(packageDir, "dist", "index.d.ts"), constants.R_OK); + const { stdout } = await run( + "npm", + ["pack", packageDir, "--json", "--pack-destination", packDir], + { cwd: repo }, + ); + const result = JSON.parse(stdout); + assert.equal(result.length, 1, `npm pack returned ${result.length} results`); + return join(packDir, result[0].filename); +} + +const nodeEsmConsumer = String.raw` +import assert from "node:assert/strict"; +import { OfflineEvaluatorScalus, TransactionUnspentOutput, fromTxUnspentOutput } from "@meshsdk/core-cst"; +import { ScalusEmulator } from "@meshsdk/scalus-emulator"; +import { scriptTxCborHex, scriptUtxoCborHex } from "./scalus-v3.mjs"; + +const utxo = fromTxUnspentOutput( + TransactionUnspentOutput.fromCbor("82" + scriptUtxoCborHex.slice(2)), +); +const evaluator = new OfflineEvaluatorScalus( + { fetchUTxOs: async () => [utxo] }, + "preview", +); +assert.deepEqual(await evaluator.evaluateTx(scriptTxCborHex, [utxo]), [ + { tag: "REWARD", index: 0, budget: { mem: 32318, steps: 8754898 } }, +]); +const provider = await ScalusEmulator.create(); +assert.ok((await provider.fetchProtocolParameters()).minFeeA > 0); +assert.equal((await provider.fetchCostModels()).length, 3); +assert.deepEqual(await provider.fetchUTxOs("00".repeat(32)), []); +console.log("ESM package exports passed"); +`; + +const nodeCjsConsumer = String.raw` +const assert = require("node:assert/strict"); +const { OfflineEvaluatorScalus, TransactionUnspentOutput, fromTxUnspentOutput } = require("@meshsdk/core-cst"); +const { ScalusEmulator } = require("@meshsdk/scalus-emulator"); + +(async () => { + const { scriptTxCborHex, scriptUtxoCborHex } = await import("./scalus-v3.mjs"); + const utxo = fromTxUnspentOutput( + TransactionUnspentOutput.fromCbor("82" + scriptUtxoCborHex.slice(2)), + ); + const evaluator = new OfflineEvaluatorScalus( + { fetchUTxOs: async () => [utxo] }, + "preview", + ); + assert.deepEqual(await evaluator.evaluateTx(scriptTxCborHex, [utxo]), [ + { tag: "REWARD", index: 0, budget: { mem: 32318, steps: 8754898 } }, + ]); + const provider = await ScalusEmulator.create(); + assert.ok((await provider.fetchProtocolParameters()).minFeeA > 0); + assert.equal((await provider.fetchCostModels()).length, 3); + assert.deepEqual(await provider.fetchUTxOs("00".repeat(32)), []); + console.log("CJS package exports passed"); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); +`; + +const browserConsumer = String.raw` +import { Buffer } from "buffer"; +import { OfflineEvaluatorScalus, TransactionUnspentOutput, fromTxUnspentOutput } from "@meshsdk/core-cst"; +import { ScalusEmulator } from "@meshsdk/scalus-emulator"; +import { scriptTxCborHex, scriptUtxoCborHex } from "./scalus-v3.mjs"; + +globalThis.Buffer = Buffer; + +async function main() { + const utxo = fromTxUnspentOutput( + TransactionUnspentOutput.fromCbor("82" + scriptUtxoCborHex.slice(2)), + ); + const budgets = await new OfflineEvaluatorScalus( + { fetchUTxOs: async () => [utxo] }, + "preview", + ).evaluateTx(scriptTxCborHex, [utxo]); + if (JSON.stringify(budgets) !== JSON.stringify([ + { tag: "REWARD", index: 0, budget: { mem: 32318, steps: 8754898 } }, + ])) throw new Error("unexpected browser evaluator budget: " + JSON.stringify(budgets)); + const provider = await ScalusEmulator.create(); + if ((await provider.fetchProtocolParameters()).minFeeA <= 0) + throw new Error("invalid protocol parameters"); + if ((await provider.fetchCostModels()).length !== 3) + throw new Error("invalid cost models"); + if ((await provider.fetchUTxOs("00".repeat(32))).length !== 0) + throw new Error("unexpected UTxOs"); + document.body.dataset.result = "passed"; + document.body.textContent = "SCALUS_BROWSER_TEST_PASSED"; +} + +main().catch((error) => { + document.body.dataset.result = "failed"; + document.body.textContent = "SCALUS_BROWSER_TEST_FAILED: " + (error?.stack ?? error); +}); +`; + +const typeConsumer = String.raw` +import type { IFetcher } from "@meshsdk/common"; +import { OfflineEvaluatorScalus } from "@meshsdk/core-cst"; +import { ScalusEmulator } from "@meshsdk/scalus-emulator"; + +const fetcher = { fetchUTxOs: async () => [] } as unknown as IFetcher; +const evaluator = new OfflineEvaluatorScalus(fetcher, "preview"); +const evaluation: Promise = evaluator.evaluateTx("80"); + +async function useProvider(): Promise { + const provider = await ScalusEmulator.create(); + await provider.fetchProtocolParameters(); + await provider.fetchCostModels(); + await provider.fetchUTxOs("00".repeat(32)); +} + +// @ts-expect-error initial UTxOs must be an array +void ScalusEmulator.create("invalid"); +void evaluation; +void useProvider(); +`; + +async function findChrome() { + const candidates = [ + process.env.CHROME_BIN, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ].filter(Boolean); + for (const candidate of candidates) { + try { + await access(candidate, constants.X_OK); + return candidate; + } catch {} + } + throw new Error( + "Chrome/Chromium was not found. Set CHROME_BIN to an executable browser path.", + ); +} + +async function serve(directory, callback) { + const server = createServer(async (request, response) => { + try { + const pathname = new URL(request.url, "http://localhost").pathname; + const filename = pathname === "/" ? "index.html" : pathname.slice(1); + if (!/^[a-zA-Z0-9._-]+$/.test(filename)) throw new Error("invalid path"); + const body = await readFile(join(directory, filename)); + response.writeHead(200, { + "content-type": filename.endsWith(".js") + ? "text/javascript" + : "text/html; charset=utf-8", + }); + response.end(body); + } catch { + response.writeHead(404).end("not found"); + } + }); + await new Promise((resolvePromise, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolvePromise); + }); + try { + const { port } = server.address(); + return await callback(`http://127.0.0.1:${port}/`); + } finally { + await new Promise((resolvePromise) => server.close(resolvePromise)); + } +} + +async function main() { + if (Number(process.versions.node.split(".")[0]) < 20) + throw new Error("This integration test requires Node.js 20 or newer"); + + const workspace = await mkdtemp(join(tmpdir(), "mesh-scalus-packages-")); + try { + const packDir = join(workspace, "packs"); + await mkdir(packDir, { recursive: true }); + const tarballs = []; + for (const packageName of packages) + tarballs.push(await pack(packageName, packDir)); + + const scalus = process.env.SCALUS_TARBALL + ? resolve(process.env.SCALUS_TARBALL) + : "scalus@^1.2.1"; + if (process.env.SCALUS_TARBALL) await access(scalus, constants.R_OK); + await writeFile( + join(workspace, "package.json"), + JSON.stringify({ private: true, type: "module" }, null, 2) + "\n", + ); + await run( + "npm", + [ + "install", + "--no-package-lock", + "--no-save", + ...tarballs, + scalus, + "buffer@^6.0.3", + "crypto-browserify@^3.12.1", + "events@^3.3.0", + "stream-browserify@^3.0.0", + ], + { cwd: workspace }, + ); + const installedScalus = JSON.parse( + await readFile( + join(workspace, "node_modules/scalus/package.json"), + "utf8", + ), + ); + const [scalusMajor, scalusMinor] = installedScalus.version + .split(".") + .map(Number); + assert.ok( + scalusMajor === 1 && scalusMinor >= 2, + `expected Scalus ^1.2.1, installed ${installedScalus.version}`, + ); + + await copyFile(fixture, join(workspace, "scalus-v3.mjs")); + await writeFile(join(workspace, "consumer.mjs"), nodeEsmConsumer); + await writeFile(join(workspace, "consumer.cjs"), nodeCjsConsumer); + process.stdout.write( + (await run(process.execPath, ["consumer.mjs"], { cwd: workspace })) + .stdout, + ); + process.stdout.write( + (await run(process.execPath, ["consumer.cjs"], { cwd: workspace })) + .stdout, + ); + await writeFile(join(workspace, "consumer.ts"), typeConsumer); + await writeFile( + join(workspace, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + target: "ES2022", + strict: true, + noEmit: true, + skipLibCheck: true, + }, + files: ["consumer.ts"], + }, + null, + 2, + ) + "\n", + ); + const tsc = createRequire( + join(repo, "packages/mesh-core-cst/package.json"), + ).resolve("typescript/bin/tsc"); + await run(process.execPath, [tsc, "--project", "tsconfig.json"], { + cwd: workspace, + }); + console.log("TypeScript package declarations passed"); + + await writeFile(join(workspace, "browser-entry.js"), browserConsumer); + const browserGlobals = join(workspace, "browser-globals.js"); + await writeFile( + browserGlobals, + 'import { Buffer } from "buffer"; globalThis.Buffer = Buffer; export { Buffer };\n', + ); + await build({ + entryPoints: [join(workspace, "browser-entry.js")], + outfile: join(workspace, "browser.js"), + bundle: true, + format: "iife", + platform: "browser", + target: "es2022", + absWorkingDir: workspace, + inject: [browserGlobals], + alias: { + crypto: "crypto-browserify", + events: "events", + stream: "stream-browserify", + }, + logLevel: "warning", + }); + assert.doesNotMatch( + await readFile(join(workspace, "browser.js"), "utf8"), + /["']node:crypto["']/, + "browser bundle retained a Node-only node:crypto import", + ); + await writeFile( + join(workspace, "index.html"), + 'SCALUS_BROWSER_TEST_RUNNING' + + '' + + '', + ); + const chrome = await findChrome(); + await serve(workspace, async (url) => { + const { stdout, stderr } = await runChrome( + chrome, + [ + "--headless=new", + "--disable-gpu", + "--dump-dom", + "--virtual-time-budget=30000", + `--user-data-dir=${join(workspace, "chrome-profile")}`, + url, + ], + { cwd: workspace }, + ); + assert.match( + stdout, + /data-result="passed"[^>]*>SCALUS_BROWSER_TEST_PASSED/, + `browser runtime did not pass\n${stdout}\n${stderr}`, + ); + }); + console.log("Browser package exports passed"); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});