diff --git a/package.json b/package.json index 7c7670df..f9378b42 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,11 @@ "typescript": "^5.9.2", "vitest": "^3.2.4" }, + "pnpm": { + "overrides": { + "scalus": "^0.17.0" + } + }, "engines": { "node": ">=18.0.0", "pnpm": ">=8.0.0" diff --git a/packages/evolution-devnet/package.json b/packages/evolution-devnet/package.json index 8727ff0f..be5593b0 100644 --- a/packages/evolution-devnet/package.json +++ b/packages/evolution-devnet/package.json @@ -49,6 +49,7 @@ "peerDependencies": { "@evolution-sdk/aiken-uplc": "workspace:*", "@evolution-sdk/evolution": "workspace:*", + "@evolution-sdk/scalus-emulator": "workspace:*", "@evolution-sdk/scalus-uplc": "workspace:*", "@effect/platform": "^0.90.10", "@effect/platform-node": "^0.96.1", diff --git a/packages/evolution-devnet/test/TxBuilder.AddSigner.NodeEmulator.test.ts b/packages/evolution-devnet/test/TxBuilder.AddSigner.NodeEmulator.test.ts new file mode 100644 index 00000000..79965268 --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.AddSigner.NodeEmulator.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "@effect/vitest" +import { Cardano } from "@evolution-sdk/evolution" +import * as KeyHash from "@evolution-sdk/evolution/KeyHash" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" + +import { createNodeEmulatorSetup } from "./utils/nodeEmulator.js" + +describe("TxBuilder addSigner (node-emulator)", () => { + it("should include requiredSigners in transaction body and submit successfully", async () => { + const { client, genesisUtxos } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const paymentCredential = myAddress.paymentCredential + if (paymentCredential._tag !== "KeyHash") throw new Error("Expected KeyHash credential") + + const signBuilder = await client + .newTx() + .addSigner({ keyHash: paymentCredential }) + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + .build({ availableUtxos: [...genesisUtxos] }) + + const tx = await signBuilder.toTransaction() + + expect(tx.body.requiredSigners).toBeDefined() + expect(tx.body.requiredSigners?.length).toBe(1) + expect(tx.body.requiredSigners?.[0]._tag).toBe("KeyHash") + expect(KeyHash.toHex(tx.body.requiredSigners![0])).toBe(KeyHash.toHex(paymentCredential)) + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should support multi-sig with partial signing and assembly", async () => { + const { client: client1, genesisUtxos } = await createNodeEmulatorSetup({ accountIndex: 0 }) + const setup2 = await createNodeEmulatorSetup({ accountIndex: 1 }) + const client2 = setup2.client + + const address1 = await client1.address() + const address2 = await client2.address() + + const credential1 = address1.paymentCredential + const credential2 = address2.paymentCredential + if (credential1._tag !== "KeyHash" || credential2._tag !== "KeyHash") { + throw new Error("Expected KeyHash credentials") + } + + const signBuilder = await client1 + .newTx() + .addSigner({ keyHash: credential1 }) + .addSigner({ keyHash: credential2 }) + .payToAddress({ + address: address1, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + .build({ availableUtxos: [...genesisUtxos] }) + + const tx = await signBuilder.toTransaction() + + expect(tx.body.requiredSigners).toBeDefined() + expect(tx.body.requiredSigners?.length).toBe(2) + + const requiredHashes = tx.body.requiredSigners!.map((k) => KeyHash.toHex(k)) + expect(requiredHashes).toContain(KeyHash.toHex(credential1)) + expect(requiredHashes).toContain(KeyHash.toHex(credential2)) + + const witness1 = await signBuilder.partialSign() + expect(witness1.vkeyWitnesses?.length).toBe(1) + + const witness2 = await client2.signTx(tx) + expect(witness2.vkeyWitnesses?.length).toBe(1) + + const submitBuilder = await signBuilder.assemble([witness1, witness2]) + + const txHash = await submitBuilder.submit() + expect(TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client1.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.Chain.NodeEmulator.test.ts b/packages/evolution-devnet/test/TxBuilder.Chain.NodeEmulator.test.ts new file mode 100644 index 00000000..3420a182 --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.Chain.NodeEmulator.test.ts @@ -0,0 +1,88 @@ +import "@evolution-sdk/scalus-emulator" + +import { describe, expect, it } from "@effect/vitest" +import { Cardano } from "@evolution-sdk/evolution" +import * as Assets from "@evolution-sdk/evolution/Assets" +import type { SignBuilder } from "@evolution-sdk/evolution/sdk/builders/SignBuilder" +import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl" +import * as SlotConfig from "@evolution-sdk/evolution/Time/SlotConfig" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" +import * as UTxO from "@evolution-sdk/evolution/UTxO" + +const TEST_MNEMONIC = + "test test test test test test test test test test test test test test test test test test test test test test test sauce" + +/** + * Same chain-submit test as TxBuilder.Chain.test.ts, but backed by the in-process + * Scalus node-emulator — no docker cluster, no kupo, no ogmios. + */ +describe("TxBuilder.chainResult (node-emulator)", () => { + it("should chain multiple transactions and submit them all", async () => { + // Build the wallet's address first (via a no-provider client). + const tempClient = createClient({ + network: 0, + wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" } + }) + const address = await tempClient.address() + + // Seed the emulator with one large UTxO for the wallet. + const genesisTxId = TransactionHash.fromHex("00".repeat(32)) + const genesisUtxos: ReadonlyArray = [ + new UTxO.UTxO({ + transactionId: genesisTxId, + index: 0n, + address, + assets: Assets.fromLovelace(500_000_000_000n) + }) + ] + + const slotConfig = SlotConfig.SLOT_CONFIG_NETWORK.Preprod + + const client = createClient({ + network: 0, + slotConfig, + provider: { + type: "node-emulator", + slotConfig, + initialUtxos: genesisUtxos + }, + wallet: { + type: "seed", + mnemonic: TEST_MNEMONIC, + accountIndex: 0, + addressType: "Base" + } + }) + + const TX_COUNT = 5 + + let available = [...genesisUtxos] + const txs: Array = [] + + for (let i = 0; i < TX_COUNT; i++) { + const tx = await client + .newTx() + .payToAddress({ address, assets: Cardano.Assets.fromLovelace(10_000_000n) }) + .build({ availableUtxos: available }) + txs.push(tx) + available = [...tx.chainResult().available] + } + + const txHashes = txs.map((tx) => tx.chainResult().txHash) + expect(new Set(txHashes).size).toBe(TX_COUNT) + + const submittedHashes: Array = [] + for (const tx of txs) { + const hash = await tx.signAndSubmit() + submittedHashes.push(hash) + } + + for (let i = 0; i < TX_COUNT; i++) { + expect(TransactionHash.toHex(submittedHashes[i])).toBe(txs[i].chainResult().txHash) + } + + for (const hash of submittedHashes) { + expect(await client.awaitTx(hash, 1000)).toBe(true) + } + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.Compose.NodeEmulator.test.ts b/packages/evolution-devnet/test/TxBuilder.Compose.NodeEmulator.test.ts new file mode 100644 index 00000000..8341bc9d --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.Compose.NodeEmulator.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "@effect/vitest" +import { Cardano } from "@evolution-sdk/evolution" +import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl" +import { createNodeEmulatorSetup, TEST_MNEMONIC } from "./utils/nodeEmulator.js" + +const Time = Cardano.Time + +describe("TxBuilder compose (node-emulator)", () => { + it("should compose payment with validity constraints", async () => { + const { client, genesisUtxos } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const paymentBuilder = client.newTx().payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + + const validityBuilder = client.newTx().setValidity({ + to: Time.now() + 300_000n + }) + + const signBuilder = await client + .newTx() + .compose(paymentBuilder) + .compose(validityBuilder) + .build({ availableUtxos: [...genesisUtxos] }) + + const tx = await signBuilder.toTransaction() + + expect(tx.body.ttl).toBeDefined() + expect(tx.body.ttl).toBeGreaterThan(0n) + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(Cardano.TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should compose multiple payment builders to different addresses", async () => { + const { client: client1, genesisUtxos } = await createNodeEmulatorSetup() + const client2 = createClient({ + network: 0, + wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex: 1, addressType: "Base" } + }) + + const address1 = await client1.address() + const address2 = await client2.address() + + const payment1 = client1.newTx().payToAddress({ + address: address1, + assets: Cardano.Assets.fromLovelace(3_000_000n) + }) + const payment2 = client1.newTx().payToAddress({ + address: address2, + assets: Cardano.Assets.fromLovelace(2_000_000n) + }) + const payment3 = client1.newTx().payToAddress({ + address: address1, + assets: Cardano.Assets.fromLovelace(4_000_000n) + }) + + const signBuilder = await client1 + .newTx() + .compose(payment1) + .compose(payment2) + .compose(payment3) + .build({ availableUtxos: [...genesisUtxos] }) + + const tx = await signBuilder.toTransaction() + expect(tx.body.outputs.length).toBeGreaterThanOrEqual(3) + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(Cardano.TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client1.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should compose builder with addSigner + metadata + payment", async () => { + const { client } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const paymentCredential = myAddress.paymentCredential + if (paymentCredential._tag !== "KeyHash") throw new Error("Expected KeyHash credential") + + const signerBuilder = client.newTx().addSigner({ keyHash: paymentCredential }) + const metadataBuilder = client.newTx().attachMetadata({ + label: 674n, + metadata: "Multi-sig transaction" + }) + const paymentBuilder = client.newTx().payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(6_000_000n) + }) + + const signBuilder = await client + .newTx() + .compose(signerBuilder) + .compose(metadataBuilder) + .compose(paymentBuilder) + .build() + + const tx = await signBuilder.toTransaction() + + expect(tx.body.requiredSigners?.length).toBe(1) + expect(tx.auxiliaryData).toBeDefined() + expect(tx.body.outputs.length).toBeGreaterThanOrEqual(1) + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(Cardano.TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should compose stake registration with payment and metadata", async () => { + const { client } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + if (!("stakingCredential" in myAddress) || !myAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + + const stakeCredential = myAddress.stakingCredential + + const stakeBuilder = client.newTx().registerStake({ stakeCredential }) + const paymentBuilder = client.newTx().payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(10_000_000n) + }) + const metadataBuilder = client.newTx().attachMetadata({ + label: 674n, + metadata: "Stake registration transaction" + }) + + const signBuilder = await client + .newTx() + .compose(stakeBuilder) + .compose(paymentBuilder) + .compose(metadataBuilder) + .build() + + const tx = await signBuilder.toTransaction() + + expect(tx.body.certificates).toBeDefined() + expect(tx.body.certificates?.length).toBe(1) + expect(tx.body.outputs.length).toBeGreaterThanOrEqual(1) + expect(tx.auxiliaryData).toBeDefined() + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(Cardano.TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should verify getPrograms returns accumulated operations", async () => { + const { client } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const builder = client + .newTx() + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(1_000_000n) + }) + .attachMetadata({ label: 1n, metadata: "Test" }) + + const programs = builder.getPrograms() + expect(programs.length).toBe(2) + + builder.payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(2_000_000n) + }) + + const programs2 = builder.getPrograms() + expect(programs2.length).toBe(3) + expect(programs.length).toBe(2) + }) + + it("should compose builders created from different clients", async () => { + const { client: client1 } = await createNodeEmulatorSetup() + const setup2 = await createNodeEmulatorSetup({ accountIndex: 1 }) + const client2 = setup2.client + + const address1 = await client1.address() + const address2 = await client2.address() + + const builder1 = client1.newTx().payToAddress({ + address: address1, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + const builder2 = client2.newTx().attachMetadata({ + label: 42n, + metadata: "Cross-client composition" + }) + + const signBuilder = await client1 + .newTx() + .compose(builder1) + .compose(builder2) + .payToAddress({ + address: address2, + assets: Cardano.Assets.fromLovelace(3_000_000n) + }) + .build() + + const tx = await signBuilder.toTransaction() + expect(tx.body.outputs.length).toBeGreaterThanOrEqual(2) + expect(tx.auxiliaryData).toBeDefined() + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(Cardano.TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client1.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.Governance.NodeEmulator.test.ts b/packages/evolution-devnet/test/TxBuilder.Governance.NodeEmulator.test.ts new file mode 100644 index 00000000..22dd385b --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.Governance.NodeEmulator.test.ts @@ -0,0 +1,179 @@ +import "@evolution-sdk/scalus-emulator" + +import { describe, expect, it } from "@effect/vitest" +import * as Anchor from "@evolution-sdk/evolution/Anchor" +import * as Assets from "@evolution-sdk/evolution/Assets" +import * as Bytes from "@evolution-sdk/evolution/Bytes" +import * as Bytes32 from "@evolution-sdk/evolution/Bytes32" +import * as Credential from "@evolution-sdk/evolution/Credential" +import * as KeyHash from "@evolution-sdk/evolution/KeyHash" +import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl" +import * as SlotConfig from "@evolution-sdk/evolution/Time/SlotConfig" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" +import * as Url from "@evolution-sdk/evolution/Url" +import * as UTxO from "@evolution-sdk/evolution/UTxO" + +const TEST_MNEMONIC = + "test test test test test test test test test test test test test test test test test test test test test test test sauce" + +const slotConfig = SlotConfig.SLOT_CONFIG_NETWORK.Preprod + +async function setupMultiAccountEmulator(accountCount: number) { + const accounts = Array.from({ length: accountCount }, (_, i) => + createClient({ + network: 0, + wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex: i, addressType: "Base" as const } + }) + ) + const addresses = await Promise.all(accounts.map((c) => c.address())) + + const genesisTxId = TransactionHash.fromHex("00".repeat(32)) + const initialUtxos = addresses.map( + (addr, i) => + new UTxO.UTxO({ + transactionId: genesisTxId, + index: BigInt(i), + address: addr, + assets: Assets.fromLovelace(300_000_000_000n) + }) + ) + + const makeClient = (accountIndex: number) => + createClient({ + network: 0, + slotConfig, + provider: { + type: "node-emulator", + slotConfig, + initialUtxos + }, + wallet: { + type: "seed", + mnemonic: TEST_MNEMONIC, + accountIndex, + addressType: "Base" + } + }) + + return { addresses, initialUtxos, makeClient } +} + +describe("TxBuilder Governance Operations (node-emulator)", () => { + it("registerDRep - registers a DRep with anchor", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + const drepCredential = walletAddress.paymentCredential + + const anchor = new Anchor.Anchor({ + anchorUrl: new Url.Url({ href: "https://example.com/drep-metadata.json" }), + anchorDataHash: Bytes32.fromHex("0000000000000000000000000000000000000000000000000000000000000000") + }) + const registerTxHash = await client + .newTx() + .registerDRep({ drepCredential, anchor }) + .build({ availableUtxos: [initialUtxos[0]] }) + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + }) + + it("updateDRep - updates DRep metadata anchor", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + const drepCredential = walletAddress.paymentCredential + + const initialAnchor = new Anchor.Anchor({ + anchorUrl: new Url.Url({ href: "https://example.com/drep-v1.json" }), + anchorDataHash: Bytes32.fromHex("1111111111111111111111111111111111111111111111111111111111111111") + }) + + const registerTxHash = await client + .newTx() + .registerDRep({ drepCredential, anchor: initialAnchor }) + .build({ availableUtxos: [initialUtxos[0]] }) + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + + const updatedAnchor = new Anchor.Anchor({ + anchorUrl: new Url.Url({ href: "https://example.com/drep-v2.json" }), + anchorDataHash: Bytes32.fromHex("2222222222222222222222222222222222222222222222222222222222222222") + }) + + const updateTxHash = await client + .newTx() + .updateDRep({ drepCredential, anchor: updatedAnchor }) + .build() + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(updateTxHash, 1000)).toBe(true) + }) + + it("deregisterDRep - deregisters a DRep and reclaims deposit", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + const drepCredential = walletAddress.paymentCredential + + const registerTxHash = await client + .newTx() + .registerDRep({ drepCredential }) + .build({ availableUtxos: [initialUtxos[0]] }) + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + + const deregisterTxHash = await client + .newTx() + .deregisterDRep({ drepCredential }) + .build() + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) + + it("authCommitteeHot - authorizes hot credential for committee", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + const coldCredential = walletAddress.paymentCredential + + const hotKeyHashBytes = KeyHash.fromHex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + const hotCredential = Credential.makeKeyHash(hotKeyHashBytes.hash) + const authTxHash = await client + .newTx() + .authCommitteeHot({ coldCredential, hotCredential }) + .build({ availableUtxos: [initialUtxos[0]] }) + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(authTxHash, 1000)).toBe(true) + }) + + it("resignCommitteeCold - resigns from constitutional committee", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + const coldCredential = walletAddress.paymentCredential + + const anchor = new Anchor.Anchor({ + anchorUrl: new Url.Url({ href: "https://example.com/resignation.json" }), + anchorDataHash: Bytes32.fromHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") + }) + const resignTxHash = await client + .newTx() + .resignCommitteeCold({ coldCredential, anchor }) + .build({ availableUtxos: [initialUtxos[0]] }) + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(resignTxHash, 1000)).toBe(true) + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.Metadata.NodeEmulator.test.ts b/packages/evolution-devnet/test/TxBuilder.Metadata.NodeEmulator.test.ts new file mode 100644 index 00000000..bdaa56db --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.Metadata.NodeEmulator.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "@effect/vitest" +import { Cardano } from "@evolution-sdk/evolution" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" +import { fromEntries } from "@evolution-sdk/evolution/TransactionMetadatum" + +import { createNodeEmulatorSetup } from "./utils/nodeEmulator.js" + +describe("TxBuilder attachMetadata (node-emulator)", () => { + it("should attach simple text metadata (CIP-20 message) and submit successfully", async () => { + const { client, genesisUtxos } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const signBuilder = await client + .newTx() + .attachMetadata({ + label: 674n, + metadata: "Hello from Evolution SDK!" + }) + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + .build({ availableUtxos: [...genesisUtxos] }) + + const tx = await signBuilder.toTransaction() + + expect(tx.auxiliaryData).toBeDefined() + if (tx.auxiliaryData && tx.auxiliaryData._tag === "ConwayAuxiliaryData") { + expect(tx.auxiliaryData.metadata?.size).toBe(1) + expect(tx.auxiliaryData.metadata?.has(674n)).toBe(true) + expect(tx.auxiliaryData.metadata?.get(674n)).toBe("Hello from Evolution SDK!") + } + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should attach multiple metadata entries with different labels", async () => { + const { client } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const signBuilder = await client + .newTx() + .attachMetadata({ label: 674n, metadata: "Transaction comment" }) + .attachMetadata({ label: 1n, metadata: 42n }) + .attachMetadata({ label: 2n, metadata: new Uint8Array([1, 2, 3, 4]) }) + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + .build() + + const tx = await signBuilder.toTransaction() + + expect(tx.auxiliaryData).toBeDefined() + if (tx.auxiliaryData && tx.auxiliaryData._tag === "ConwayAuxiliaryData") { + expect(tx.auxiliaryData.metadata?.size).toBe(3) + expect(tx.auxiliaryData.metadata?.get(674n)).toBe("Transaction comment") + expect(tx.auxiliaryData.metadata?.get(1n)).toBe(42n) + const bytesMetadata = tx.auxiliaryData.metadata?.get(2n) as Uint8Array + expect(bytesMetadata).toBeInstanceOf(Uint8Array) + expect(Array.from(bytesMetadata)).toEqual([1, 2, 3, 4]) + } + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should attach complex NFT-like metadata (CIP-25 style)", async () => { + const { client } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const nftMetadata = fromEntries([ + ["name", "Evolution SDK Test NFT"], + ["image", "ipfs://QmTestHash123"], + ["description", "A test NFT minted with Evolution SDK"], + [ + "attributes", + [ + fromEntries([["trait_type", "Rarity"], ["value", "Common"]]), + fromEntries([["trait_type", "Edition"], ["value", 1n]]) + ] + ] + ]) + + const signBuilder = await client + .newTx() + .attachMetadata({ label: 721n, metadata: nftMetadata }) + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + .build() + + const tx = await signBuilder.toTransaction() + + expect(tx.auxiliaryData).toBeDefined() + if (tx.auxiliaryData && tx.auxiliaryData._tag === "ConwayAuxiliaryData") { + expect(tx.auxiliaryData.metadata?.size).toBe(1) + const metadata = tx.auxiliaryData.metadata?.get(721n) + expect(metadata).toBeInstanceOf(Map) + if (metadata instanceof Map) { + expect(metadata.get("name")).toBe("Evolution SDK Test NFT") + expect(metadata.get("image")).toBe("ipfs://QmTestHash123") + const attributes = metadata.get("attributes") + expect(Array.isArray(attributes)).toBe(true) + if (Array.isArray(attributes)) { + expect(attributes.length).toBe(2) + expect(attributes[0]).toBeInstanceOf(Map) + } + } + } + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.Mint.NodeEmulator.test.ts b/packages/evolution-devnet/test/TxBuilder.Mint.NodeEmulator.test.ts new file mode 100644 index 00000000..26a83a7a --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.Mint.NodeEmulator.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "@effect/vitest" +import { Cardano } from "@evolution-sdk/evolution" +import * as CoreAddress from "@evolution-sdk/evolution/Address" +import * as AssetName from "@evolution-sdk/evolution/AssetName" +import * as NativeScripts from "@evolution-sdk/evolution/NativeScripts" +import * as PolicyId from "@evolution-sdk/evolution/PolicyId" +import * as ScriptHash from "@evolution-sdk/evolution/ScriptHash" +import * as Text from "@evolution-sdk/evolution/Text" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" + +import { createNodeEmulatorSetup } from "./utils/nodeEmulator.js" + +const CoreAssets = Cardano.Assets + +describe("TxBuilder Minting (node-emulator)", () => { + it("should mint, submit and find asset in UTxO", async () => { + const { client, genesisUtxos, address } = await createNodeEmulatorSetup({ lovelace: 900_000_000_000n }) + + const paymentKeyHash = address.paymentCredential.hash + const nativeScript = NativeScripts.makeScriptPubKey(paymentKeyHash) + const scriptHash = ScriptHash.fromScript(nativeScript) + const policyId = ScriptHash.toHex(scriptHash) + + const assetNameHex = Text.toHex("IntegrationToken") + const unit = policyId + assetNameHex + + const genesisUtxo = genesisUtxos.find((u) => CoreAddress.toBech32(u.address) === CoreAddress.toBech32(address)) + if (!genesisUtxo) throw new Error("Genesis UTxO not found") + + const signBuilder = await client + .newTx() + .attachScript({ script: nativeScript }) + .mintAssets({ + assets: CoreAssets.fromRecord({ [unit]: 5000n }) + }) + .payToAddress({ + address, + assets: CoreAssets.fromRecord({ + lovelace: 3_000_000n, + [unit]: 5000n + }) + }) + .build({ availableUtxos: [genesisUtxo] }) + + const tx = await signBuilder.toTransaction() + expect(tx.body.mint).toBeDefined() + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + + const utxos = await client.getWalletUtxos() + let foundMintedAsset = false + let mintedAmount = 0n + + for (const utxo of utxos) { + if (!utxo.assets.multiAsset) continue + for (const [policyIdKey, assetMap] of utxo.assets.multiAsset.map.entries()) { + if (PolicyId.toHex(policyIdKey) === policyId) { + for (const [assetName, amount] of assetMap.entries()) { + if (AssetName.toHex(assetName) === assetNameHex) { + foundMintedAsset = true + mintedAmount = amount + } + } + } + } + } + + expect(foundMintedAsset).toBe(true) + expect(mintedAmount).toBe(5000n) + }) + + it("should handle burning (negative amounts) with submit", async () => { + const { client, genesisUtxos, address } = await createNodeEmulatorSetup({ lovelace: 900_000_000_000n }) + + const paymentKeyHash = address.paymentCredential.hash + const nativeScript = NativeScripts.makeScriptPubKey(paymentKeyHash) + const scriptHash = ScriptHash.fromScript(nativeScript) + const policyId = ScriptHash.toHex(scriptHash) + + const ASSET_NAME = "TestToken" + const assetNameHex = Text.toHex(ASSET_NAME) + const unit = policyId + assetNameHex + + // Step 1: Mint tokens + const mintBuilder = await client + .newTx() + .attachScript({ script: nativeScript }) + .mintAssets({ + assets: CoreAssets.fromRecord({ [unit]: 1000n }) + }) + .payToAddress({ + address, + assets: CoreAssets.fromRecord({ + lovelace: 3_000_000n, + [unit]: 1000n + }) + }) + .build({ availableUtxos: [...genesisUtxos] }) + + const mintTx = await mintBuilder.toTransaction() + expect(mintTx.body.mint).toBeDefined() + + const mintSubmitBuilder = await mintBuilder.sign() + const mintTxHash = await mintSubmitBuilder.submit() + const mintConfirmed = await client.awaitTx(mintTxHash, 1000) + expect(mintConfirmed).toBe(true) + + // Step 2: Get UTxO with minted tokens + const utxos = await client.getWalletUtxos() + const utxoWithTokens = utxos.find((u) => CoreAssets.getByUnit(u.assets, unit) > 0n) + if (!utxoWithTokens) throw new Error("UTxO with minted tokens not found") + + // Step 3: Burn some tokens + const burnBuilder = await client + .newTx() + .attachScript({ script: nativeScript }) + .collectFrom({ inputs: [utxoWithTokens] }) + .mintAssets({ + assets: CoreAssets.fromRecord({ [unit]: -500n }) + }) + .payToAddress({ + address, + assets: CoreAssets.fromRecord({ + lovelace: 1_500_000n, + [unit]: 500n + }) + }) + .build({ availableUtxos: [] }) + + const burnTx = await burnBuilder.toTransaction() + expect(burnTx.body.mint).toBeDefined() + + let foundBurn = false + for (const [policyIdKey, assetMap] of burnTx.body.mint!.map.entries()) { + if (PolicyId.toHex(policyIdKey) === policyId) { + for (const [assetName, amount] of assetMap.entries()) { + if (AssetName.toHex(assetName) === assetNameHex && amount === -500n) { + foundBurn = true + } + } + } + } + expect(foundBurn).toBe(true) + + const burnSubmitBuilder = await burnBuilder.sign() + const burnTxHash = await burnSubmitBuilder.submit() + expect(TransactionHash.toHex(burnTxHash).length).toBe(64) + + const burnConfirmed = await client.awaitTx(burnTxHash, 1000) + expect(burnConfirmed).toBe(true) + + const utxosAfterBurn = await client.getWalletUtxos() + let remainingTokenAmount = 0n + for (const utxo of utxosAfterBurn) { + remainingTokenAmount += CoreAssets.getByUnit(utxo.assets, unit) + } + expect(remainingTokenAmount).toBe(500n) + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.Pool.NodeEmulator.test.ts b/packages/evolution-devnet/test/TxBuilder.Pool.NodeEmulator.test.ts new file mode 100644 index 00000000..c2bf6150 --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.Pool.NodeEmulator.test.ts @@ -0,0 +1,193 @@ +import "@evolution-sdk/scalus-emulator" + +import { describe, expect, it } from "@effect/vitest" +import * as Address from "@evolution-sdk/evolution/Address" +import * as Assets from "@evolution-sdk/evolution/Assets" +import * as Bytes32 from "@evolution-sdk/evolution/Bytes32" +import type * as EpochNo from "@evolution-sdk/evolution/EpochNo" +import * as IPv4 from "@evolution-sdk/evolution/IPv4" +import * as KeyHash from "@evolution-sdk/evolution/KeyHash" +import * as PoolKeyHash from "@evolution-sdk/evolution/PoolKeyHash" +import * as PoolMetadata from "@evolution-sdk/evolution/PoolMetadata" +import * as PoolParams from "@evolution-sdk/evolution/PoolParams" +import * as RewardAccount from "@evolution-sdk/evolution/RewardAccount" +import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl" +import * as SingleHostAddr from "@evolution-sdk/evolution/SingleHostAddr" +import * as SlotConfig from "@evolution-sdk/evolution/Time/SlotConfig" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" +import * as UnitInterval from "@evolution-sdk/evolution/UnitInterval" +import * as Url from "@evolution-sdk/evolution/Url" +import * as UTxO from "@evolution-sdk/evolution/UTxO" +import * as VrfKeyHash from "@evolution-sdk/evolution/VrfKeyHash" + +const TEST_MNEMONIC = + "test test test test test test test test test test test test test test test test test test test test test test test sauce" + +const slotConfig = SlotConfig.SLOT_CONFIG_NETWORK.Preprod + +async function setupMultiAccountEmulator(accountCount: number) { + const accounts = Array.from({ length: accountCount }, (_, i) => + createClient({ + network: 0, + wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex: i, addressType: "Base" as const } + }) + ) + const addresses = await Promise.all(accounts.map((c) => c.address())) + + const genesisTxId = TransactionHash.fromHex("00".repeat(32)) + const initialUtxos = addresses.map( + (addr, i) => + new UTxO.UTxO({ + transactionId: genesisTxId, + index: BigInt(i), + address: addr, + assets: Assets.fromLovelace(1_000_000_000_000n) + }) + ) + + const makeClient = (accountIndex: number) => + createClient({ + network: 0, + slotConfig, + provider: { + type: "node-emulator", + slotConfig, + initialUtxos + }, + wallet: { + type: "seed", + mnemonic: TEST_MNEMONIC, + accountIndex, + addressType: "Base" + } + }) + + return { addresses, initialUtxos, makeClient } +} + +describe("TxBuilder Pool Operations (node-emulator)", () => { + it("registerPool - registers a new stake pool", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + const genesisUtxo = initialUtxos.find((u) => Address.toBech32(u.address) === Address.toBech32(walletAddress)) + if (!genesisUtxo) throw new Error("Genesis UTxO not found") + + const poolKeyHash = + walletAddress.paymentCredential._tag === "KeyHash" + ? new PoolKeyHash.PoolKeyHash({ hash: walletAddress.paymentCredential.hash }) + : PoolKeyHash.fromHex("8a219b698d3b6e034391ae84cee62f1d76b6fbc45ddfe4e31e0d4b60") + const vrfKeyhash = VrfKeyHash.fromHex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + + const rewardAccount = new RewardAccount.RewardAccount({ + networkId: 0, + stakeCredential: walletAddress.stakingCredential! + }) + + const ownerKeyHash = + walletAddress.paymentCredential._tag === "KeyHash" + ? walletAddress.paymentCredential + : KeyHash.fromHex("cccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + + const poolMetadata = new PoolMetadata.PoolMetadata({ + url: new Url.Url({ href: "https://example.com/pool-metadata.json" }), + hash: Bytes32.fromHex("dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd") + }) + + const relay = new SingleHostAddr.SingleHostAddr({ + port: 3001n, + ipv4: new IPv4.IPv4({ bytes: new Uint8Array([192, 168, 1, 100]) }), + ipv6: undefined + }) + + const poolParams = new PoolParams.PoolParams({ + operator: poolKeyHash, + vrfKeyhash, + pledge: 100_000_000_000n, + cost: 340_000_000n, + margin: new UnitInterval.UnitInterval({ + numerator: 3n, + denominator: 100n + }), + rewardAccount, + poolOwners: [ownerKeyHash], + relays: [relay], + poolMetadata + }) + + const registerTxHash = await client + .newTx() + .registerPool({ poolParams }) + .build({ availableUtxos: [genesisUtxo] }) + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + }) + + it("retirePool - retires a stake pool", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(2) + const client = makeClient(1) + const walletAddress = await client.address() + + const genesisUtxo = initialUtxos.find((u) => Address.toBech32(u.address) === Address.toBech32(walletAddress)) + if (!genesisUtxo) throw new Error("Genesis UTxO not found") + + const poolKeyHash = + walletAddress.paymentCredential._tag === "KeyHash" + ? new PoolKeyHash.PoolKeyHash({ hash: walletAddress.paymentCredential.hash }) + : PoolKeyHash.fromHex("9a229b698d3b6e034391ae84cee62f1d76b6fbc45ddfe4e31e0d4b70") + const vrfKeyhash = VrfKeyHash.fromHex("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + + const rewardAccount = new RewardAccount.RewardAccount({ + networkId: 0, + stakeCredential: walletAddress.stakingCredential! + }) + + const ownerKeyHash = + walletAddress.paymentCredential._tag === "KeyHash" + ? walletAddress.paymentCredential + : KeyHash.fromHex("cccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + + const relay = new SingleHostAddr.SingleHostAddr({ + port: 3001n, + ipv4: new IPv4.IPv4({ bytes: new Uint8Array([192, 168, 1, 101]) }), + ipv6: undefined + }) + + const poolParams = new PoolParams.PoolParams({ + operator: poolKeyHash, + vrfKeyhash, + pledge: 100_000_000_000n, + cost: 340_000_000n, + margin: new UnitInterval.UnitInterval({ + numerator: 5n, + denominator: 100n + }), + rewardAccount, + poolOwners: [ownerKeyHash], + relays: [relay], + poolMetadata: undefined + }) + + const registerTxHash = await client + .newTx() + .registerPool({ poolParams }) + .build({ availableUtxos: [genesisUtxo] }) + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + + const retirementEpoch: EpochNo.EpochNo = 5n + const retireTxHash = await client + .newTx() + .retirePool({ poolKeyHash, epoch: retirementEpoch }) + .build() + .then((b) => b.sign()) + .then((b) => b.submit()) + + expect(await client.awaitTx(retireTxHash, 1000)).toBe(true) + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.Stake.NodeEmulator.test.ts b/packages/evolution-devnet/test/TxBuilder.Stake.NodeEmulator.test.ts new file mode 100644 index 00000000..75952609 --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.Stake.NodeEmulator.test.ts @@ -0,0 +1,280 @@ +import "@evolution-sdk/scalus-emulator" + +import { describe, expect, it } from "@effect/vitest" +import * as Address from "@evolution-sdk/evolution/Address" +import * as Assets from "@evolution-sdk/evolution/Assets" +import * as DRep from "@evolution-sdk/evolution/DRep" +import * as PoolKeyHash from "@evolution-sdk/evolution/PoolKeyHash" +import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl" +import * as SlotConfig from "@evolution-sdk/evolution/Time/SlotConfig" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" +import * as UTxO from "@evolution-sdk/evolution/UTxO" + +const TEST_MNEMONIC = + "test test test test test test test test test test test test test test test test test test test test test test test sauce" + +const DEVNET_POOL_ID = "8a219b698d3b6e034391ae84cee62f1d76b6fbc45ddfe4e31e0d4b60" +const slotConfig = SlotConfig.SLOT_CONFIG_NETWORK.Preprod + +async function setupMultiAccountEmulator(accountCount: number) { + const accounts = Array.from({ length: accountCount }, (_, i) => + createClient({ + network: 0, + wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex: i, addressType: "Base" as const } + }) + ) + const addresses = await Promise.all(accounts.map((c) => c.address())) + + const genesisTxId = TransactionHash.fromHex("00".repeat(32)) + const initialUtxos = addresses.map( + (addr, i) => + new UTxO.UTxO({ + transactionId: genesisTxId, + index: BigInt(i), + address: addr, + assets: Assets.fromLovelace(300_000_000_000n) + }) + ) + + const makeClient = (accountIndex: number) => + createClient({ + network: 0, + slotConfig, + provider: { + type: "node-emulator", + slotConfig, + initialUtxos + }, + wallet: { + type: "seed", + mnemonic: TEST_MNEMONIC, + accountIndex, + addressType: "Base" + } + }) + + return { addresses, initialUtxos, makeClient } +} + +describe("TxBuilder Stake Operations (node-emulator)", () => { + it("registers, delegates, withdraws, and deregisters (key credential)", async () => { + const { makeClient, initialUtxos, addresses } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + if (!("stakingCredential" in walletAddress) || !walletAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + const stakeCredential = walletAddress.stakingCredential + + const genesisUtxo = initialUtxos.find((u) => Address.toBech32(u.address) === Address.toBech32(walletAddress)) + if (!genesisUtxo) throw new Error("Genesis UTxO not found") + + // Step 1: Register + const registerTxHash = await client + .newTx() + .registerStake({ stakeCredential }) + .build({ availableUtxos: [genesisUtxo] }) + .then((b) => b.sign()) + .then((b) => b.submit()) + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + + // Step 2: Delegate to pool AND DRep + const poolKeyHash = PoolKeyHash.fromHex(DEVNET_POOL_ID) + const drep = new DRep.AlwaysAbstainDRep({}) + + const delegateTxHash = await client + .newTx() + .delegateTo({ stakeCredential, poolKeyHash, drep }) + .build() + .then((b) => b.sign()) + .then((b) => b.submit()) + expect(await client.awaitTx(delegateTxHash, 1000)).toBe(true) + + // Step 3: Withdraw rewards (0) + const withdrawTxHash = await client + .newTx() + .withdraw({ stakeCredential, amount: 0n }) + .build() + .then((b) => b.sign()) + .then((b) => b.submit()) + expect(await client.awaitTx(withdrawTxHash, 1000)).toBe(true) + + // Step 4: Deregister + const deregisterTxHash = await client + .newTx() + .deregisterStake({ stakeCredential }) + .build() + .then((b) => b.sign()) + .then((b) => b.submit()) + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) + + it("delegates to pool only (StakeDelegation)", async () => { + const { makeClient, initialUtxos, addresses } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + if (!("stakingCredential" in walletAddress) || !walletAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + const stakeCredential = walletAddress.stakingCredential + const genesisUtxo = initialUtxos[0] + + // Register + const registerTxHash = await client.newTx().registerStake({ stakeCredential }).build({ availableUtxos: [genesisUtxo] }).then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + + // Delegate to pool + const poolKeyHash = PoolKeyHash.fromHex(DEVNET_POOL_ID) + const delegateTxHash = await client.newTx().delegateTo({ stakeCredential, poolKeyHash }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(delegateTxHash, 1000)).toBe(true) + + // Deregister + const deregisterTxHash = await client.newTx().deregisterStake({ stakeCredential }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) + + it("delegates to DRep only (VoteDelegCert)", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + if (!("stakingCredential" in walletAddress) || !walletAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + const stakeCredential = walletAddress.stakingCredential + + // Register + const registerTxHash = await client.newTx().registerStake({ stakeCredential }).build({ availableUtxos: [initialUtxos[0]] }).then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + + // Delegate to DRep + const drep = new DRep.AlwaysNoConfidenceDRep({}) + const delegateTxHash = await client.newTx().delegateTo({ stakeCredential, drep }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(delegateTxHash, 1000)).toBe(true) + + // Deregister + const deregisterTxHash = await client.newTx().deregisterStake({ stakeCredential }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) + + it("registers and delegates to pool in one cert (StakeRegDelegCert)", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + if (!("stakingCredential" in walletAddress) || !walletAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + const stakeCredential = walletAddress.stakingCredential + + const poolKeyHash = PoolKeyHash.fromHex(DEVNET_POOL_ID) + const txHash = await client.newTx().registerAndDelegateTo({ stakeCredential, poolKeyHash }).build({ availableUtxos: [initialUtxos[0]] }).then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(txHash, 1000)).toBe(true) + + const deregisterTxHash = await client.newTx().deregisterStake({ stakeCredential }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) + + it("registers and delegates to DRep in one cert (VoteRegDelegCert)", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + if (!("stakingCredential" in walletAddress) || !walletAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + const stakeCredential = walletAddress.stakingCredential + + const drep = new DRep.AlwaysNoConfidenceDRep({}) + const txHash = await client.newTx().registerAndDelegateTo({ stakeCredential, drep }).build({ availableUtxos: [initialUtxos[0]] }).then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(txHash, 1000)).toBe(true) + + const deregisterTxHash = await client.newTx().deregisterStake({ stakeCredential }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) + + it("registers and delegates to both pool+DRep in one cert (StakeVoteRegDelegCert)", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + if (!("stakingCredential" in walletAddress) || !walletAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + const stakeCredential = walletAddress.stakingCredential + + const poolKeyHash = PoolKeyHash.fromHex(DEVNET_POOL_ID) + const drep = new DRep.AlwaysAbstainDRep({}) + const txHash = await client.newTx().registerAndDelegateTo({ stakeCredential, poolKeyHash, drep }).build({ availableUtxos: [initialUtxos[0]] }).then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(txHash, 1000)).toBe(true) + + const deregisterTxHash = await client.newTx().deregisterStake({ stakeCredential }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) + + it("NEW API: delegateToPool - delegates stake to pool only", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + if (!("stakingCredential" in walletAddress) || !walletAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + const stakeCredential = walletAddress.stakingCredential + + const registerTxHash = await client.newTx().registerStake({ stakeCredential }).build({ availableUtxos: [initialUtxos[0]] }).then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + + const poolKeyHash = PoolKeyHash.fromHex(DEVNET_POOL_ID) + const delegateTxHash = await client.newTx().delegateToPool({ stakeCredential, poolKeyHash }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(delegateTxHash, 1000)).toBe(true) + + const deregisterTxHash = await client.newTx().deregisterStake({ stakeCredential }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) + + it("NEW API: delegateToDRep - delegates voting power to DRep only", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + if (!("stakingCredential" in walletAddress) || !walletAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + const stakeCredential = walletAddress.stakingCredential + + const registerTxHash = await client.newTx().registerStake({ stakeCredential }).build({ availableUtxos: [initialUtxos[0]] }).then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + + const drep = new DRep.AlwaysAbstainDRep({}) + const delegateTxHash = await client.newTx().delegateToDRep({ stakeCredential, drep }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(delegateTxHash, 1000)).toBe(true) + + const deregisterTxHash = await client.newTx().deregisterStake({ stakeCredential }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) + + it("NEW API: delegateToPoolAndDRep - delegates both stake and voting power", async () => { + const { makeClient, initialUtxos } = await setupMultiAccountEmulator(1) + const client = makeClient(0) + const walletAddress = await client.address() + + if (!("stakingCredential" in walletAddress) || !walletAddress.stakingCredential) { + throw new Error("Expected BaseAddress with stakingCredential") + } + const stakeCredential = walletAddress.stakingCredential + + const registerTxHash = await client.newTx().registerStake({ stakeCredential }).build({ availableUtxos: [initialUtxos[0]] }).then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(registerTxHash, 1000)).toBe(true) + + const poolKeyHash = PoolKeyHash.fromHex(DEVNET_POOL_ID) + const drep = new DRep.AlwaysNoConfidenceDRep({}) + const delegateTxHash = await client.newTx().delegateToPoolAndDRep({ stakeCredential, poolKeyHash, drep }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(delegateTxHash, 1000)).toBe(true) + + const deregisterTxHash = await client.newTx().deregisterStake({ stakeCredential }).build().then((b) => b.sign()).then((b) => b.submit()) + expect(await client.awaitTx(deregisterTxHash, 1000)).toBe(true) + }) +}) diff --git a/packages/evolution-devnet/test/TxBuilder.Validity.NodeEmulator.test.ts b/packages/evolution-devnet/test/TxBuilder.Validity.NodeEmulator.test.ts new file mode 100644 index 00000000..42323b7a --- /dev/null +++ b/packages/evolution-devnet/test/TxBuilder.Validity.NodeEmulator.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "@effect/vitest" +import { Cardano } from "@evolution-sdk/evolution" + +import { createNodeEmulatorSetup } from "./utils/nodeEmulator.js" + +const Time = Cardano.Time + +describe("TxBuilder Validity Interval (node-emulator)", () => { + it("should build and submit transaction with TTL", async () => { + const { client, genesisUtxos } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const ttl = Time.now() + 300_000n + + const signBuilder = await client + .newTx() + .setValidity({ to: ttl }) + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + .build({ availableUtxos: [...genesisUtxos] }) + + const tx = await signBuilder.toTransaction() + + expect(tx.body.ttl).toBeDefined() + expect(typeof tx.body.ttl).toBe("bigint") + expect(tx.body.ttl! > 0n).toBe(true) + expect(tx.body.validityIntervalStart).toBeUndefined() + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(Cardano.TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should build and submit transaction with both validity bounds", async () => { + const { client } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const from = Time.now() + const to = Time.now() + 300_000n + + const signBuilder = await client + .newTx() + .setValidity({ from, to }) + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + .build() + + const tx = await signBuilder.toTransaction() + + expect(tx.body.ttl).toBeDefined() + expect(tx.body.ttl! > 0n).toBe(true) + expect(tx.body.validityIntervalStart).toBeDefined() + expect(tx.body.validityIntervalStart! > 0n).toBe(true) + expect(tx.body.ttl! > tx.body.validityIntervalStart!).toBe(true) + + const submitBuilder = await signBuilder.sign() + const txHash = await submitBuilder.submit() + expect(Cardano.TransactionHash.toHex(txHash).length).toBe(64) + + const confirmed = await client.awaitTx(txHash, 1000) + expect(confirmed).toBe(true) + }) + + it("should reject expired transaction", async () => { + const { client, genesisUtxos } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const expiredTtl = Time.now() - 1_000n + + const signBuilder = await client + .newTx() + .setValidity({ to: expiredTtl }) + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + .build({ availableUtxos: [...genesisUtxos] }) + + const submitBuilder = await signBuilder.sign() + + await expect(submitBuilder.submit()).rejects.toThrow() + }) + + it("should reject transaction before validity start", async () => { + const { client, genesisUtxos } = await createNodeEmulatorSetup() + const myAddress = await client.address() + + const from = Time.now() + 300_000n + const to = Time.now() + 600_000n + + const signBuilder = await client + .newTx() + .setValidity({ from, to }) + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n) + }) + .build({ availableUtxos: [...genesisUtxos] }) + + const submitBuilder = await signBuilder.sign() + + await expect(submitBuilder.submit()).rejects.toThrow() + }) +}) diff --git a/packages/evolution-devnet/test/utils/nodeEmulator.ts b/packages/evolution-devnet/test/utils/nodeEmulator.ts new file mode 100644 index 00000000..309741b7 --- /dev/null +++ b/packages/evolution-devnet/test/utils/nodeEmulator.ts @@ -0,0 +1,69 @@ +import "@evolution-sdk/scalus-emulator" + +import * as Assets from "@evolution-sdk/evolution/Assets" +import * as Address from "@evolution-sdk/evolution/Address" +import * as SlotConfig from "@evolution-sdk/evolution/Time/SlotConfig" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" +import * as UTxO from "@evolution-sdk/evolution/UTxO" +import { createClient } from "@evolution-sdk/evolution/sdk/client/ClientImpl" +import type { NodeEmulatorConfig } from "@evolution-sdk/evolution/sdk/client/Client" +import type { SigningClient } from "@evolution-sdk/evolution/sdk/client/Client" +import type { SlotConfig as SlotConfigType } from "@evolution-sdk/evolution/Time/SlotConfig" +import type * as Cardano from "@evolution-sdk/evolution" + +export const TEST_MNEMONIC = + "test test test test test test test test test test test test test test test test test test test test test test test sauce" + +export const DEVNET_SLOT_CONFIG = SlotConfig.SLOT_CONFIG_NETWORK.Preprod + +export interface EmulatorTestSetup { + client: SigningClient + genesisUtxos: ReadonlyArray + address: Address.Address + slotConfig: SlotConfigType +} + +export async function createNodeEmulatorSetup(opts?: { + accountIndex?: number + lovelace?: bigint + nodeEmulatorOverrides?: Partial +}): Promise { + const accountIndex = opts?.accountIndex ?? 0 + const lovelace = opts?.lovelace ?? 500_000_000_000n + const slotConfig = opts?.nodeEmulatorOverrides?.slotConfig ?? DEVNET_SLOT_CONFIG + + const tempClient = createClient({ + network: 0, + wallet: { type: "seed", mnemonic: TEST_MNEMONIC, accountIndex, addressType: "Base" } + }) + const address = await tempClient.address() + + const genesisTxId = TransactionHash.fromHex("00".repeat(32)) + const genesisUtxos: ReadonlyArray = [ + new UTxO.UTxO({ + transactionId: genesisTxId, + index: 0n, + address, + assets: Assets.fromLovelace(lovelace) + }) + ] + + const client = createClient({ + network: 0, + slotConfig, + provider: { + type: "node-emulator", + slotConfig, + initialUtxos: genesisUtxos, + ...opts?.nodeEmulatorOverrides + }, + wallet: { + type: "seed", + mnemonic: TEST_MNEMONIC, + accountIndex, + addressType: "Base" + } + }) + + return { client, genesisUtxos, address, slotConfig } +} diff --git a/packages/evolution/package.json b/packages/evolution/package.json index 6d31c896..f7eef935 100644 --- a/packages/evolution/package.json +++ b/packages/evolution/package.json @@ -50,6 +50,14 @@ "tsx": "^4.20.4", "typescript": "^5.9.2" }, + "peerDependencies": { + "@evolution-sdk/scalus-emulator": "workspace:*" + }, + "peerDependenciesMeta": { + "@evolution-sdk/scalus-emulator": { + "optional": true + } + }, "dependencies": { "@effect/platform": "^0.90.10", "@effect/platform-node": "^0.96.1", diff --git a/packages/evolution/src/sdk/client/Client.ts b/packages/evolution/src/sdk/client/Client.ts index 1f060922..ec34d82d 100644 --- a/packages/evolution/src/sdk/client/Client.ts +++ b/packages/evolution/src/sdk/client/Client.ts @@ -3,6 +3,7 @@ import { Data, type Effect, type Schedule } from "effect" import type * as CoreUTxO from "../../UTxO.js" import type { ReadOnlyTransactionBuilder, SigningTransactionBuilder } from "../builders/TransactionBuilder.js" import type * as Provider from "../provider/Provider.js" +import type { SlotConfig } from "../../Time/SlotConfig.js" import type { EffectToPromiseAPI } from "../Type.js" import type { ApiWalletEffect, ReadOnlyWalletEffect, SigningWalletEffect, WalletApi, WalletError } from "../wallet/WalletNew.js" @@ -260,13 +261,52 @@ export interface KoiosConfig { readonly retryPolicy?: RetryPolicy } +/** + * Node-emulator provider configuration. + * + * Backed by the Scalus emulator (compiled from Scala via Scala.js), this runs an + * in-process Cardano node simulation. + * + */ +export interface NodeEmulatorConfig { + readonly type: "node-emulator" + readonly slotConfig: SlotConfig + /** Initial UTxOs to seed the ledger with. */ + readonly initialUtxos: ReadonlyArray + /** Optional protocol parameters. Falls back to emulator defaults. */ + readonly protocolParameters?: Provider.ProtocolParameters + /** Pre-registered stake credentials with rewards and optional delegation. */ + readonly stakeRegistrations?: ReadonlyArray<{ + readonly credentialType: "key" | "script" + readonly credentialHash: string + readonly rewards: bigint + readonly delegatedTo?: string + }> + /** Pre-registered stake pools. */ + readonly poolRegistrations?: ReadonlyArray<{ + readonly params: Uint8Array + }> + /** Pre-registered DReps. */ + readonly drepRegistrations?: ReadonlyArray<{ + readonly credentialType: "key" | "script" + readonly credentialHash: string + readonly deposit: bigint + readonly anchor?: Uint8Array + }> + /** Pre-seeded datum store entries (hex hash -> hex CBOR datum). */ + readonly datums?: ReadonlyArray<{ + readonly hash: string + readonly datum: string + }> +} + /** * Provider configuration union type. * * @since 2.0.0 * @category model */ -export type ProviderConfig = BlockfrostConfig | KupmiosConfig | MaestroConfig | KoiosConfig +export type ProviderConfig = BlockfrostConfig | KupmiosConfig | MaestroConfig | KoiosConfig | NodeEmulatorConfig /** * Seed phrase wallet configuration. diff --git a/packages/evolution/src/sdk/client/ClientImpl.ts b/packages/evolution/src/sdk/client/ClientImpl.ts index edbf0841..bcec802b 100644 --- a/packages/evolution/src/sdk/client/ClientImpl.ts +++ b/packages/evolution/src/sdk/client/ClientImpl.ts @@ -33,6 +33,7 @@ import { type MinimalClient, type MinimalClientEffect, type NetworkId, + type NodeEmulatorConfig, type PrivateKeyWalletConfig, type ProviderConfig, type ProviderOnlyClient, @@ -61,9 +62,40 @@ const createProvider = (config: ProviderConfig): Provider.Provider => { return new Maestro.MaestroProvider(config.baseUrl, config.apiKey, config.turboSubmit) case "koios": return new Koios.Koios(config.baseUrl, config.token) + case "node-emulator": + return createNodeEmulatorProvider(config) } } +/** + * Factory for the `node-emulator` provider. Registered by `@evolution-sdk/scalus-emulator` + * on import — we keep this indirection so `@evolution-sdk/evolution` doesn't have to + * statically depend on the emulator package (which would cause a cycle, since the + * emulator package peer-depends on evolution). + */ +export type NodeEmulatorProviderFactory = (config: NodeEmulatorConfig) => Provider.Provider + +let nodeEmulatorProviderFactory: NodeEmulatorProviderFactory | null = null + +/** + * Register the factory that constructs a Scalus-backed emulator provider. + * Called from `@evolution-sdk/scalus-emulator` at import time. + */ +export const registerNodeEmulatorProviderFactory = (factory: NodeEmulatorProviderFactory): void => { + nodeEmulatorProviderFactory = factory +} + +const createNodeEmulatorProvider = (config: NodeEmulatorConfig): Provider.Provider => { + if (!nodeEmulatorProviderFactory) { + throw new Error( + "node-emulator provider requires `@evolution-sdk/scalus-emulator`. " + + "Install it and add `import \"@evolution-sdk/scalus-emulator\"` (or any import from it) " + + "in your entry point to register the factory." + ) + } + return nodeEmulatorProviderFactory(config) +} + /** * Map NetworkId to numeric representation. * "mainnet" → 1, "preprod"/"preview" → 0, numeric values pass through unchanged. diff --git a/packages/scalus-emulator/package.json b/packages/scalus-emulator/package.json new file mode 100644 index 00000000..0f352b95 --- /dev/null +++ b/packages/scalus-emulator/package.json @@ -0,0 +1,49 @@ +{ + "name": "@evolution-sdk/scalus-emulator", + "version": "0.0.1", + "description": "Scalus Emulator provider for Evolution SDK", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": [], + "tags": [ + "typescript", + "cardano", + "scalus", + "emulator", + "provider" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "node": "./src/index.ts", + "default": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "files": [ + "src/**/*.ts", + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json", + "dev": "tsc -b tsconfig.build.json --watch", + "type-check": "tsc --noEmit", + "lint": "eslint \"src/**/*.{ts,mjs}\"", + "clean": "rm -rf dist .turbo .tsbuildinfo" + }, + "dependencies": { + "effect": "^3.19.3", + "scalus": "^0.17.0" + }, + "peerDependencies": { + "@evolution-sdk/evolution": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.9.2" + } +} diff --git a/packages/scalus-emulator/src/DefaultCostModels.ts b/packages/scalus-emulator/src/DefaultCostModels.ts new file mode 100644 index 00000000..4b0f6176 --- /dev/null +++ b/packages/scalus-emulator/src/DefaultCostModels.ts @@ -0,0 +1,51 @@ +// TODO: extract default cost models to @evolution-sdk/evolution core so they can be shared +import * as CostModel from "@evolution-sdk/evolution/CostModel" + +const PLUTUS_V1_COSTS: ReadonlyArray = [ + 100788n, 420n, 1n, 1n, 1000n, 173n, 0n, 1n, 1000n, 59957n, 4n, 1n, 11183n, 32n, 201305n, 8356n, 4n, 16000n, 100n, + 16000n, 100n, 16000n, 100n, 16000n, 100n, 16000n, 100n, 16000n, 100n, 100n, 100n, 16000n, 100n, 94375n, 32n, 132994n, + 32n, 61462n, 4n, 72010n, 178n, 0n, 1n, 22151n, 32n, 91189n, 769n, 4n, 2n, 85848n, 228465n, 122n, 0n, 1n, 1n, 1000n, + 42921n, 4n, 2n, 24548n, 29498n, 38n, 1n, 898148n, 27279n, 1n, 51775n, 558n, 1n, 39184n, 1000n, 60594n, 1n, 141895n, + 32n, 83150n, 32n, 15299n, 32n, 76049n, 1n, 13169n, 4n, 22100n, 10n, 28999n, 74n, 1n, 28999n, 74n, 1n, 43285n, 552n, + 1n, 44749n, 541n, 1n, 33852n, 32n, 68246n, 32n, 72362n, 32n, 7243n, 32n, 7391n, 32n, 11546n, 32n, 85848n, 228465n, + 122n, 0n, 1n, 1n, 90434n, 519n, 0n, 1n, 74433n, 32n, 85848n, 228465n, 122n, 0n, 1n, 1n, 85848n, 228465n, 122n, 0n, + 1n, 1n, 270652n, 22588n, 4n, 1457325n, 64566n, 4n, 20467n, 1n, 4n, 0n, 141992n, 32n, 100788n, 420n, 1n, 1n, 81663n, + 32n, 59498n, 32n, 20142n, 32n, 24588n, 32n, 20744n, 32n, 25933n, 32n, 24623n, 32n, 53384111n, 14333n, 10n +] + +const PLUTUS_V2_COSTS: ReadonlyArray = [ + 100788n, 420n, 1n, 1n, 1000n, 173n, 0n, 1n, 1000n, 59957n, 4n, 1n, 11183n, 32n, 201305n, 8356n, 4n, 16000n, 100n, + 16000n, 100n, 16000n, 100n, 16000n, 100n, 16000n, 100n, 16000n, 100n, 100n, 100n, 16000n, 100n, 94375n, 32n, 132994n, + 32n, 61462n, 4n, 72010n, 178n, 0n, 1n, 22151n, 32n, 91189n, 769n, 4n, 2n, 85848n, 228465n, 122n, 0n, 1n, 1n, 1000n, + 42921n, 4n, 2n, 24548n, 29498n, 38n, 1n, 898148n, 27279n, 1n, 51775n, 558n, 1n, 39184n, 1000n, 60594n, 1n, 141895n, + 32n, 83150n, 32n, 15299n, 32n, 76049n, 1n, 13169n, 4n, 22100n, 10n, 28999n, 74n, 1n, 28999n, 74n, 1n, 43285n, 552n, + 1n, 44749n, 541n, 1n, 33852n, 32n, 68246n, 32n, 72362n, 32n, 7243n, 32n, 7391n, 32n, 11546n, 32n, 85848n, 228465n, + 122n, 0n, 1n, 1n, 90434n, 519n, 0n, 1n, 74433n, 32n, 85848n, 228465n, 122n, 0n, 1n, 1n, 85848n, 228465n, 122n, 0n, + 1n, 1n, 955506n, 213312n, 0n, 2n, 270652n, 22588n, 4n, 1457325n, 64566n, 4n, 20467n, 1n, 4n, 0n, 141992n, 32n, + 100788n, 420n, 1n, 1n, 81663n, 32n, 59498n, 32n, 20142n, 32n, 24588n, 32n, 20744n, 32n, 25933n, 32n, 24623n, 32n, + 43053543n, 10n, 53384111n, 14333n, 10n, 43574283n, 26308n, 10n +] + +const PLUTUS_V3_COSTS: ReadonlyArray = [ + 100788n, 420n, 1n, 1n, 1000n, 173n, 0n, 1n, 1000n, 59957n, 4n, 1n, 11183n, 32n, 201305n, 8356n, 4n, 16000n, 100n, + 16000n, 100n, 16000n, 100n, 16000n, 100n, 16000n, 100n, 16000n, 100n, 100n, 100n, 16000n, 100n, 94375n, 32n, 132994n, + 32n, 61462n, 4n, 72010n, 178n, 0n, 1n, 22151n, 32n, 91189n, 769n, 4n, 2n, 85848n, 123203n, 7305n, -900n, 1716n, 549n, + 57n, 85848n, 0n, 1n, 1n, 1000n, 42921n, 4n, 2n, 24548n, 29498n, 38n, 1n, 898148n, 27279n, 1n, 51775n, 558n, 1n, + 39184n, 1000n, 60594n, 1n, 141895n, 32n, 83150n, 32n, 15299n, 32n, 76049n, 1n, 13169n, 4n, 22100n, 10n, 28999n, 74n, + 1n, 28999n, 74n, 1n, 43285n, 552n, 1n, 44749n, 541n, 1n, 33852n, 32n, 68246n, 32n, 72362n, 32n, 7243n, 32n, 7391n, + 32n, 11546n, 32n, 85848n, 123203n, 7305n, -900n, 1716n, 549n, 57n, 85848n, 0n, 1n, 90434n, 519n, 0n, 1n, 74433n, 32n, + 85848n, 123203n, 7305n, -900n, 1716n, 549n, 57n, 85848n, 0n, 1n, 1n, 85848n, 123203n, 7305n, -900n, 1716n, 549n, 57n, + 85848n, 0n, 1n, 955506n, 213312n, 0n, 2n, 270652n, 22588n, 4n, 1457325n, 64566n, 4n, 20467n, 1n, 4n, 0n, 141992n, + 32n, 100788n, 420n, 1n, 1n, 81663n, 32n, 59498n, 32n, 20142n, 32n, 24588n, 32n, 20744n, 32n, 25933n, 32n, 24623n, + 32n, 43053543n, 10n, 53384111n, 14333n, 10n, 43574283n, 26308n, 10n, 16000n, 100n, 16000n, 100n, 962335n, 18n, + 2780678n, 6n, 442008n, 1n, 52538055n, 3756n, 18n, 267929n, 18n, 76433006n, 8868n, 18n, 52948122n, 18n, 1995836n, 36n, + 3227919n, 12n, 901022n, 1n, 166917843n, 4307n, 36n, 284546n, 36n, 158221314n, 26549n, 36n, 74698472n, 36n, 333849714n, + 1n, 254006273n, 72n, 2174038n, 72n, 2261318n, 64571n, 4n, 207616n, 8310n, 4n, 1293828n, 28716n, 63n, 0n, 1n, 1006041n, + 43623n, 251n, 0n, 1n +] + +export const DEFAULT_COST_MODELS = new CostModel.CostModels({ + PlutusV1: new CostModel.CostModel({ costs: PLUTUS_V1_COSTS }), + PlutusV2: new CostModel.CostModel({ costs: PLUTUS_V2_COSTS }), + PlutusV3: new CostModel.CostModel({ costs: PLUTUS_V3_COSTS }) +}) diff --git a/packages/scalus-emulator/src/EmulatorProvider.ts b/packages/scalus-emulator/src/EmulatorProvider.ts new file mode 100644 index 00000000..329e28ab --- /dev/null +++ b/packages/scalus-emulator/src/EmulatorProvider.ts @@ -0,0 +1,354 @@ +import * as Address from "@evolution-sdk/evolution/Address" +import * as Bytes from "@evolution-sdk/evolution/Bytes" +import * as CBOR from "@evolution-sdk/evolution/CBOR" +import * as CostModel from "@evolution-sdk/evolution/CostModel" +import type * as Credential from "@evolution-sdk/evolution/Credential" +import * as Data from "@evolution-sdk/evolution/Data" +import * as DatumOption from "@evolution-sdk/evolution/DatumOption" +import * as PoolKeyHash from "@evolution-sdk/evolution/PoolKeyHash" +import * as Redeemer from "@evolution-sdk/evolution/Redeemer" +import * as RewardAccount from "@evolution-sdk/evolution/RewardAccount" +import * as Script from "@evolution-sdk/evolution/Script" +import * as ScriptRef from "@evolution-sdk/evolution/ScriptRef" +import type { EvalRedeemer } from "@evolution-sdk/evolution/sdk/EvalRedeemer" +import { ProviderError } from "@evolution-sdk/evolution/sdk/provider/Provider" +import type { Provider, ProviderEffect, ProtocolParameters } from "@evolution-sdk/evolution/sdk/provider/Provider" +import * as Transaction from "@evolution-sdk/evolution/Transaction" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" +import * as TransactionInput from "@evolution-sdk/evolution/TransactionInput" +import * as TxOut from "@evolution-sdk/evolution/TxOut" +import * as UTxO from "@evolution-sdk/evolution/UTxO" +import { Effect, Equal, Schema } from "effect" +import * as Scalus from "scalus" + +import { DEFAULT_COST_MODELS } from "./DefaultCostModels.js" + +type Emulator = Scalus.Emulator +type SlotConfig = Scalus.SlotConfig + +/** + * Decode a single CBOR-encoded UTxO entry (Map with one key-value pair) + * from the Scalus emulator into an Evolution SDK UTxO. + */ +function decodeUtxoEntry(cborBytes: Uint8Array): UTxO.UTxO { + const decoded = CBOR.internalDecodeSync(cborBytes, CBOR.CML_DEFAULT_OPTIONS) as Map + const [[key, value]] = decoded.entries() + const txInput = Schema.decodeSync(TransactionInput.FromCDDL)(key as any) + const txOutput = Schema.decodeSync(TxOut.FromCDDL)(value as any) + return new UTxO.UTxO({ + transactionId: txInput.transactionId, + index: BigInt(txInput.index), + address: txOutput.address, + assets: txOutput.assets, + datumOption: txOutput.datumOption, + scriptRef: txOutput.scriptRef ? Script.fromCBOR(txOutput.scriptRef.bytes) : undefined + }) +} + +/** + * Build CBOR-encoded UTxO map from Evolution SDK UTxOs for Scalus evaluation. + */ +export function buildUtxoMapCBOR(utxos: ReadonlyArray): Uint8Array { + const utxoMap = new Map() + for (const utxo of utxos) { + const txInput = new TransactionInput.TransactionInput({ + transactionId: utxo.transactionId, + index: utxo.index + }) + const inputCBOR = Schema.encodeSync(TransactionInput.FromCDDL)(txInput) + const scriptRef = utxo.scriptRef ? new ScriptRef.ScriptRef({ bytes: Script.toCBOR(utxo.scriptRef) }) : undefined + const txOut = new TxOut.TransactionOutput({ + address: utxo.address, + assets: utxo.assets, + datumOption: utxo.datumOption, + scriptRef + }) + const outputCBOR = Schema.encodeSync(TxOut.FromCDDL)(txOut) + utxoMap.set(inputCBOR, outputCBOR) + } + return CBOR.toCBORBytes(utxoMap, CBOR.CML_DEFAULT_OPTIONS) +} + +/** Default protocol parameters matching Cardano mainnet. */ +const DEFAULT_PROTOCOL_PARAMETERS: ProtocolParameters = { + minFeeA: 44, + minFeeB: 155381, + maxTxSize: 16384, + maxValSize: 5000, + keyDeposit: 2000000n, + poolDeposit: 500000000n, + drepDeposit: 500000000n, + govActionDeposit: 100000000000n, + priceMem: 0.0577, + priceStep: 0.0000721, + maxTxExMem: 14000000n, + maxTxExSteps: 10000000000n, + coinsPerUtxoByte: 4310n, + collateralPercentage: 150, + maxCollateralInputs: 3, + minFeeRefScriptCostPerByte: 15, + costModels: costModelsToProtocolShape(DEFAULT_COST_MODELS) +} + +function costModelsToProtocolShape(costModels: CostModel.CostModels): ProtocolParameters["costModels"] { + const toRecord = (cm: CostModel.CostModel): Record => { + const record: Record = {} + for (let i = 0; i < cm.costs.length; i++) record[String(i)] = Number(cm.costs[i]) + return record + } + return { + PlutusV1: toRecord(costModels.PlutusV1), + PlutusV2: toRecord(costModels.PlutusV2), + PlutusV3: toRecord(costModels.PlutusV3) + } +} + +function costModelsToScalusArrays(costModels: CostModel.CostModels): Array> { + return [ + costModels.PlutusV1.costs.map(Number), + costModels.PlutusV2.costs.map(Number), + costModels.PlutusV3.costs.map(Number) + ] +} + +/** Redeemer tag mapping from Scalus to Evolution SDK. */ +const REDEEMER_TAG_MAP: Record = { + Spend: "spend", + Mint: "mint", + Cert: "cert", + Reward: "reward", + Voting: "vote", + Proposing: "propose" +} + +/** + * Scalus Emulator provider for Evolution SDK. + * Implements the Provider interface backed by a local Scalus Cardano emulator. + * + * @since 0.0.1 + * @category constructors + */ +export class ScalusEmulatorProvider implements Provider { + readonly Effect: ProviderEffect + readonly emulator: Emulator + readonly slotConfig: SlotConfig + readonly protocolParameters: ProtocolParameters + readonly costModels: CostModel.CostModels + + constructor(emulator: Emulator, slotConfig: SlotConfig, protocolParameters?: ProtocolParameters, costModels?: CostModel.CostModels) { + this.emulator = emulator + this.slotConfig = slotConfig + this.costModels = costModels ?? DEFAULT_COST_MODELS + this.protocolParameters = protocolParameters ?? DEFAULT_PROTOCOL_PARAMETERS + + const self = this + + this.Effect = { + getProtocolParameters: () => Effect.succeed(self.protocolParameters), + + getUtxos: (addressOrCredential) => + Effect.try({ + try: () => { + const allEntries = self.emulator.getAllUtxos() + const utxos: Array = [] + for (const entry of allEntries) { + const utxo = decodeUtxoEntry(entry) + if (matchesAddressOrCredential(utxo.address, addressOrCredential)) { + utxos.push(utxo) + } + } + return utxos + }, + catch: (error) => new ProviderError({ cause: error, message: `Failed to get UTxOs: ${error}` }) + }), + + getUtxosWithUnit: (addressOrCredential, unit) => + Effect.try({ + try: () => { + const allEntries = self.emulator.getAllUtxos() + const utxos: Array = [] + for (const entry of allEntries) { + const utxo = decodeUtxoEntry(entry) + if (matchesAddressOrCredential(utxo.address, addressOrCredential) && hasUnit(utxo, unit)) { + utxos.push(utxo) + } + } + return utxos + }, + catch: (error) => new ProviderError({ cause: error, message: `Failed to get UTxOs with unit: ${error}` }) + }), + + getUtxoByUnit: (unit) => + Effect.try({ + try: () => { + const allEntries = self.emulator.getAllUtxos() + for (const entry of allEntries) { + const utxo = decodeUtxoEntry(entry) + if (hasUnit(utxo, unit)) { + return utxo + } + } + throw new Error(`UTxO with unit ${unit} not found`) + }, + catch: (error) => new ProviderError({ cause: error, message: `Failed to get UTxO by unit: ${error}` }) + }), + + getUtxosByOutRef: (inputs) => + Effect.try({ + try: () => { + const allEntries = self.emulator.getAllUtxos() + const utxos: Array = [] + for (const entry of allEntries) { + const utxo = decodeUtxoEntry(entry) + for (const input of inputs) { + if ( + Equal.equals(utxo.transactionId, input.transactionId) && + utxo.index === BigInt(input.index) + ) { + utxos.push(utxo) + break + } + } + } + return utxos + }, + catch: (error) => new ProviderError({ cause: error, message: `Failed to get UTxOs by outRef: ${error}` }) + }), + + getDelegation: (rewardAddress) => + Effect.try({ + try: () => { + const rewardAccount = RewardAccount.fromBech32(rewardAddress) + const cred = rewardAccount.stakeCredential + const credCbor = CBOR.toCBORBytes( + cred._tag === "KeyHash" ? [0n, cred.hash] : [1n, cred.hash], + CBOR.CML_DEFAULT_OPTIONS + ) + const info = self.emulator.getDelegation(credCbor) + const poolId = info.poolId + ? new PoolKeyHash.PoolKeyHash({ hash: info.poolId }) + : null + return { poolId, rewards: info.rewards } + }, + catch: (error) => new ProviderError({ cause: error, message: `Failed to get delegation: ${error}` }) + }), + + getDatum: (datumHash) => + Effect.try({ + try: () => { + const hashBytes = datumHash.hash + const result = self.emulator.getDatum(hashBytes) + if (!result) { + throw new Error(`Datum not found for hash: ${Bytes.toHex(hashBytes)}`) + } + return Data.fromCBORBytes(result) + }, + catch: (error) => new ProviderError({ cause: error, message: `Failed to get datum: ${error}` }) + }), + + awaitTx: (txHash) => + Effect.try({ + try: () => { + const hashBytes = TransactionHash.toBytes(txHash) + return self.emulator.hasTx(hashBytes) + }, + catch: (error) => new ProviderError({ cause: error, message: `Failed to check tx: ${error}` }) + }), + + submitTx: (tx) => + Effect.try({ + try: () => { + const txBytes = Transaction.toCBORBytes(tx) + const result = self.emulator.submitTx(txBytes) + if (!result.isSuccess) { + const logs = result.logs?.join("\n") ?? "" + throw new Error(`Transaction rejected: ${result.error}${logs ? `\nLogs:\n${logs}` : ""}`) + } + return TransactionHash.fromHex(result.txHash!) + }, + catch: (error) => new ProviderError({ cause: error, message: `Failed to submit transaction: ${error}` }) + }), + + evaluateTx: (tx, additionalUTxOs) => + Effect.try({ + try: () => { + const txBytes = Transaction.toCBORBytes(tx) + const allEntries = self.emulator.getAllUtxos() + const allUtxos = allEntries.map(decodeUtxoEntry) + if (additionalUTxOs) { + allUtxos.push(...additionalUTxOs) + } + const utxosBytes = buildUtxoMapCBOR(allUtxos) + + const costModelArrays = costModelsToScalusArrays(self.costModels) + + const scalusSlotConfig = new Scalus.SlotConfig( + self.slotConfig.slotToTime(0), + 0, + 1000 + ) + + const redeemers = Scalus.Scalus.evalPlutusScripts(txBytes, utxosBytes, scalusSlotConfig, costModelArrays) + + return redeemers.map((r): EvalRedeemer => ({ + redeemer_tag: REDEEMER_TAG_MAP[r.tag] || "spend", + redeemer_index: r.index, + ex_units: new Redeemer.ExUnits({ mem: BigInt(r.budget.memory), steps: BigInt(r.budget.steps) }) + })) + }, + catch: (error) => new ProviderError({ cause: error, message: `Failed to evaluate transaction: ${error}` }) + }) + } + } + + getProtocolParameters = () => Effect.runPromise(this.Effect.getProtocolParameters()) + getUtxos = (addressOrCredential: Parameters[0]) => + Effect.runPromise(this.Effect.getUtxos(addressOrCredential)) + getUtxosWithUnit = ( + addressOrCredential: Parameters[0], + unit: Parameters[1] + ) => Effect.runPromise(this.Effect.getUtxosWithUnit(addressOrCredential, unit)) + getUtxoByUnit = (unit: Parameters[0]) => + Effect.runPromise(this.Effect.getUtxoByUnit(unit)) + getUtxosByOutRef = (outRefs: Parameters[0]) => + Effect.runPromise(this.Effect.getUtxosByOutRef(outRefs)) + getDelegation = (rewardAddress: Parameters[0]) => + Effect.runPromise(this.Effect.getDelegation(rewardAddress)) + getDatum = (datumHash: Parameters[0]) => + Effect.runPromise(this.Effect.getDatum(datumHash)) + awaitTx = (txHash: Parameters[0], checkInterval?: Parameters[1]) => + Effect.runPromise(this.Effect.awaitTx(txHash, checkInterval)) + submitTx = (tx: Parameters[0]) => + Effect.runPromise(this.Effect.submitTx(tx)) + evaluateTx = (tx: Parameters[0], additionalUTxOs?: Parameters[1]) => + Effect.runPromise(this.Effect.evaluateTx(tx, additionalUTxOs)) +} + +/** Check if a UTxO contains a specific unit (policyId + assetName hex concatenated). */ +function hasUnit(utxo: UTxO.UTxO, unit: string): boolean { + if (unit === "lovelace") return utxo.assets.lovelace > 0n + const multiAsset = utxo.assets.multiAsset + if (!multiAsset) return false + const policyIdHex = unit.slice(0, 56) + const assetNameHex = unit.slice(56) + for (const [policyId, assets] of multiAsset.map) { + const pIdHex = Bytes.toHex(policyId.hash) + if (pIdHex !== policyIdHex) continue + for (const [assetName, quantity] of assets) { + const aNameHex = Bytes.toHex(assetName.bytes) + if (aNameHex === assetNameHex && quantity > 0n) return true + } + } + return false +} + +/** Check if an address matches an Address or Credential filter. */ +function matchesAddressOrCredential( + address: Address.Address, + filter: Address.Address | Credential.Credential +): boolean { + if (filter instanceof Address.Address) { + return Equal.equals(address, filter) + } + return Equal.equals(address.paymentCredential, filter) +} diff --git a/packages/scalus-emulator/src/index.ts b/packages/scalus-emulator/src/index.ts new file mode 100644 index 00000000..c961d9a4 --- /dev/null +++ b/packages/scalus-emulator/src/index.ts @@ -0,0 +1,39 @@ +import { registerNodeEmulatorProviderFactory } from "@evolution-sdk/evolution/sdk/client/ClientImpl" +import * as Scalus from "scalus" + +import { buildUtxoMapCBOR, ScalusEmulatorProvider } from "./EmulatorProvider.js" + +export { buildUtxoMapCBOR, ScalusEmulatorProvider } + +registerNodeEmulatorProviderFactory((config) => { + const initialUtxosCbor = buildUtxoMapCBOR(config.initialUtxos) + const slotConfig = new Scalus.SlotConfig( + Number(config.slotConfig.zeroTime), + Number(config.slotConfig.zeroSlot), + config.slotConfig.slotLength + ) + + const hasState = config.stakeRegistrations || config.poolRegistrations || + config.drepRegistrations || config.datums + + let emulator: Scalus.Emulator + if (hasState) { + const state: Scalus.EmulatorInitialState = { + utxos: initialUtxosCbor, + stakeRegistrations: config.stakeRegistrations, + poolRegistrations: config.poolRegistrations, + drepRegistrations: config.drepRegistrations, + datums: config.datums + } + emulator = Scalus.Emulator.withState(state, slotConfig) + } else { + emulator = new Scalus.Emulator(initialUtxosCbor, slotConfig) + } + + // Advance the emulator to the current wall-clock slot so that validity + // interval checks (from/to) work correctly out of the box. + const currentSlot = slotConfig.timeToSlot(Date.now()) + emulator.setSlot(currentSlot) + + return new ScalusEmulatorProvider(emulator, slotConfig, config.protocolParameters) +}) diff --git a/packages/scalus-emulator/tsconfig.build.json b/packages/scalus-emulator/tsconfig.build.json new file mode 100644 index 00000000..02c325c6 --- /dev/null +++ b/packages/scalus-emulator/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "outDir": "dist", + "types": ["node"], + "stripInternal": true + } +} diff --git a/packages/scalus-emulator/tsconfig.json b/packages/scalus-emulator/tsconfig.json new file mode 100644 index 00000000..6e773dfe --- /dev/null +++ b/packages/scalus-emulator/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "files": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" } + ] +} diff --git a/packages/scalus-emulator/tsconfig.src.json b/packages/scalus-emulator/tsconfig.src.json new file mode 100644 index 00000000..0f916616 --- /dev/null +++ b/packages/scalus-emulator/tsconfig.src.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "outDir": ".tsbuildinfo/src", + "rootDir": "src" + } +} diff --git a/packages/scalus-emulator/tsconfig.test.json b/packages/scalus-emulator/tsconfig.test.json new file mode 100644 index 00000000..cc3bdb7c --- /dev/null +++ b/packages/scalus-emulator/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "include": ["test/**/*", "**/*.test.ts", "**/*.spec.ts"], + "references": [{ "path": "tsconfig.src.json" }], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "outDir": ".tsbuildinfo/test", + "noEmit": true, + "baseUrl": ".", + "paths": { + "@evolution-sdk/scalus-emulator": ["src/index.ts"], + "@evolution-sdk/scalus-emulator/*": ["src/*/index.ts", "src/*.ts"] + } + } +} diff --git a/packages/scalus-uplc/package.json b/packages/scalus-uplc/package.json index 8aa2a711..902bd0ae 100644 --- a/packages/scalus-uplc/package.json +++ b/packages/scalus-uplc/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "effect": "^3.19.3", - "scalus": "^0.14.2" + "scalus": "^0.17.0" }, "peerDependencies": { "@evolution-sdk/evolution": "workspace:*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 924e3768..dfa2012f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + scalus: ^0.17.0 + importers: .: @@ -199,6 +202,9 @@ importers: '@effect/platform-node': specifier: ^0.96.1 version: 0.96.1(@effect/cluster@0.48.2(@effect/platform@0.90.10(effect@3.19.3))(@effect/rpc@0.69.1(@effect/platform@0.90.10(effect@3.19.3))(effect@3.19.3))(@effect/sql@0.44.2(@effect/experimental@0.54.6(@effect/platform@0.90.10(effect@3.19.3))(effect@3.19.3))(@effect/platform@0.90.10(effect@3.19.3))(effect@3.19.3))(@effect/workflow@0.9.2(@effect/platform@0.90.10(effect@3.19.3))(@effect/rpc@0.69.1(@effect/platform@0.90.10(effect@3.19.3))(effect@3.19.3))(effect@3.19.3))(effect@3.19.3))(@effect/platform@0.90.10(effect@3.19.3))(@effect/rpc@0.69.1(@effect/platform@0.90.10(effect@3.19.3))(effect@3.19.3))(@effect/sql@0.44.2(@effect/experimental@0.54.6(@effect/platform@0.90.10(effect@3.19.3))(effect@3.19.3))(@effect/platform@0.90.10(effect@3.19.3))(effect@3.19.3))(effect@3.19.3) + '@evolution-sdk/scalus-emulator': + specifier: workspace:* + version: link:../scalus-emulator '@noble/curves': specifier: ^2.0.1 version: 2.0.1 @@ -260,6 +266,9 @@ importers: '@evolution-sdk/evolution': specifier: workspace:* version: link:../evolution + '@evolution-sdk/scalus-emulator': + specifier: workspace:* + version: link:../scalus-emulator '@evolution-sdk/scalus-uplc': specifier: workspace:* version: link:../scalus-uplc @@ -283,6 +292,22 @@ importers: specifier: ^5.9.2 version: 5.9.2 + packages/scalus-emulator: + dependencies: + '@evolution-sdk/evolution': + specifier: workspace:* + version: link:../evolution + effect: + specifier: ^3.19.3 + version: 3.19.3 + scalus: + specifier: ^0.17.0 + version: 0.17.0 + devDependencies: + typescript: + specifier: ^5.9.2 + version: 5.9.2 + packages/scalus-uplc: dependencies: '@evolution-sdk/evolution': @@ -292,18 +317,12 @@ importers: specifier: ^3.19.3 version: 3.19.3 scalus: - specifier: ^0.14.2 - version: 0.14.2 + specifier: ^0.17.0 + version: 0.17.0 devDependencies: - '@effect/vitest': - specifier: ^0.19.3 - version: 0.19.10(effect@3.19.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1)) typescript: specifier: ^5.9.2 version: 5.9.2 - vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1) packages: @@ -640,12 +659,6 @@ packages: '@effect/platform': ^0.90.4 effect: ^3.17.7 - '@effect/vitest@0.19.10': - resolution: {integrity: sha512-eV+Vu/3mqqpzAzo2Cb5/ZpBnUwIeDMOy4vAGCUv5bRzKq4HiTK23yYCEB9g5G+ZkPyQ63oOSO/7GHxS5f1sNtg==} - peerDependencies: - effect: ^3.13.12 - vitest: ^3.0.0 - '@effect/vitest@0.25.1': resolution: {integrity: sha512-OMYvOU8iGed8GZXxgVBXlYtjG+jwWj5cJxFk0hOHOfTbCHXtdCMEWlXNba5zxbE7dBnW4srbnSYrP/NGGTC3qQ==} peerDependencies: @@ -5374,8 +5387,8 @@ packages: engines: {node: '>=18'} hasBin: true - scalus@0.14.2: - resolution: {integrity: sha512-dobDMIUDUVhtxoX3ceGlaykKQGkph4HOE9hjkLsmwVgYf24fIik6YrZzVFrZSNCTvI2WN7hjEknehIrEJo1CMQ==} + scalus@0.17.0: + resolution: {integrity: sha512-74mD4wL1vw4GVh2ECemQhoB3q5Smwj5S8W7OG1j7OWuLkwJ0Mvz4LFhZPA9rZjpNvanZoKiDqc3S/qGD65uRYg==} scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -6721,11 +6734,6 @@ snapshots: effect: 3.19.3 uuid: 11.1.0 - '@effect/vitest@0.19.10(effect@3.19.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1))': - dependencies: - effect: 3.19.3 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1) - '@effect/vitest@0.25.1(effect@3.19.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.4)(yaml@2.8.1))': dependencies: effect: 3.19.3 @@ -12096,7 +12104,7 @@ snapshots: commander: 12.1.0 enhanced-resolve: 5.18.3 - scalus@0.14.2: {} + scalus@0.17.0: {} scheduler@0.27.0: {}