diff --git a/package-lock.json b/package-lock.json index e9018f9a0..1f6da5791 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14943,13 +14943,14 @@ }, "packages/mesh-common": { "name": "@meshsdk/common", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", "bip39": "3.1.0", "blake2b": "^2.1.4", - "blakejs": "^1.2.1" + "blakejs": "^1.2.1", + "scalus": "^0.15.0" }, "devDependencies": { "@meshsdk/configs": "*", @@ -14959,13 +14960,19 @@ "typescript": "^5.3.3" } }, + "packages/mesh-common/node_modules/scalus": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/scalus/-/scalus-0.15.0.tgz", + "integrity": "sha512-4yWMpHkGj89/VUUn18kXZHjUHdJDSw2Se9E1Aa3xmNWSe5YdWyGclpRlqfih5mTFuQik9/vt0fgrh3HaEUVqUw==", + "license": "Apache-2.0" + }, "packages/mesh-contract": { "name": "@meshsdk/contract", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.101", - "@meshsdk/core": "1.9.0-beta.101" + "@meshsdk/common": "1.9.0-beta.102", + "@meshsdk/core": "1.9.0-beta.102" }, "devDependencies": { "@meshsdk/configs": "*", @@ -14976,14 +14983,15 @@ }, "packages/mesh-core": { "name": "@meshsdk/core", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.101", - "@meshsdk/core-cst": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", + "@meshsdk/core-cst": "1.9.0-beta.102", "@meshsdk/provider": "1.9.0-beta.100", - "@meshsdk/transaction": "1.9.0-beta.101", - "@meshsdk/wallet": "1.9.0-beta.101" + "@meshsdk/transaction": "1.9.0-beta.102", + "@meshsdk/wallet": "1.9.0-beta.102", + "scalus": "^0.15.0" }, "devDependencies": { "@meshsdk/configs": "*", @@ -14994,10 +15002,10 @@ }, "packages/mesh-core-csl": { "name": "@meshsdk/core-csl", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", "@sidan-lab/whisky-js-browser": "^1.0.11", "@sidan-lab/whisky-js-nodejs": "^1.0.11", "@types/base32-encoding": "^1.0.2", @@ -15016,7 +15024,7 @@ }, "packages/mesh-core-cst": { "name": "@meshsdk/core-cst", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "0.46.12", @@ -15027,7 +15035,7 @@ "@harmoniclabs/pair": "^1.0.0", "@harmoniclabs/plutus-data": "1.2.6", "@harmoniclabs/uplc": "1.4.1", - "@meshsdk/common": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", "@types/base32-encoding": "^1.0.2", "base32-encoding": "^1.0.0", "bech32": "^2.0.0", @@ -15046,16 +15054,22 @@ "typescript": "^5.3.3" } }, + "packages/mesh-core/node_modules/scalus": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/scalus/-/scalus-0.15.0.tgz", + "integrity": "sha512-4yWMpHkGj89/VUUn18kXZHjUHdJDSw2Se9E1Aa3xmNWSe5YdWyGclpRlqfih5mTFuQik9/vt0fgrh3HaEUVqUw==", + "license": "Apache-2.0" + }, "packages/mesh-transaction": { "name": "@meshsdk/transaction", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "0.46.12", "@cardano-sdk/input-selection": "0.14.28", "@cardano-sdk/util": "0.17.1", - "@meshsdk/common": "1.9.0-beta.101", - "@meshsdk/core-cst": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", + "@meshsdk/core-cst": "1.9.0-beta.102", "json-bigint": "^1.0.0" }, "devDependencies": { @@ -15068,12 +15082,12 @@ }, "packages/mesh-wallet": { "name": "@meshsdk/wallet", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.101", - "@meshsdk/core-cst": "1.9.0-beta.101", - "@meshsdk/transaction": "1.9.0-beta.101", + "@meshsdk/common": "1.9.0-beta.102", + "@meshsdk/core-cst": "1.9.0-beta.102", + "@meshsdk/transaction": "1.9.0-beta.102", "@simplewebauthn/browser": "^13.0.0" }, "devDependencies": { @@ -15086,7 +15100,7 @@ }, "scripts/mesh-cli": { "name": "meshjs", - "version": "1.9.0-beta.101", + "version": "1.9.0-beta.102", "license": "Apache-2.0", "dependencies": { "@sidan-lab/cardano-bar": "^0.0.7", diff --git a/packages/configs/typescript/base.json b/packages/configs/typescript/base.json index 1f621d565..bbf716503 100644 --- a/packages/configs/typescript/base.json +++ b/packages/configs/typescript/base.json @@ -23,6 +23,7 @@ "sourceMap": true, "allowJs": true, "allowSyntheticDefaultImports": true, - "noUnusedLocals": false + "noUnusedLocals": false, + "types": ["node", "jest"] } } \ No newline at end of file diff --git a/packages/mesh-core-cst/src/utils/converter.ts b/packages/mesh-core-cst/src/utils/converter.ts index 48f170d82..36449e2fb 100644 --- a/packages/mesh-core-cst/src/utils/converter.ts +++ b/packages/mesh-core-cst/src/utils/converter.ts @@ -466,3 +466,41 @@ export const toPlutusLanguageVersion = ( return PlutusLanguageVersion.V3; } }; + +export const utxosToCborMap = (utxos: UTxO[]): string => { + const cborWriter = new Serialization.CborWriter(); + cborWriter.writeStartMap(utxos.length); + for (const utxo of utxos) { + const cardanoUtxo = toTxUnspentOutput(utxo); + cborWriter.writeEncodedValue( + Buffer.from(cardanoUtxo.input().toCbor(), "hex"), + ); + cborWriter.writeEncodedValue( + Buffer.from(cardanoUtxo.output().toCbor(), "hex"), + ); + } + return cborWriter.encodeAsHex(); +}; + +export const cborMapToUtxos = (cborMaps: string[]): UTxO[] => { + const utxos: UTxO[] = []; + for (const cborMap of cborMaps) { + const cborReader = new Serialization.CborReader( + Buffer.from(cborMap, "hex"), + ); + const mapLength = cborReader.readStartMap(); + if (!mapLength) { + throw new Error("Invalid CBOR map: expected a map of UTxOs"); + } + for (let i = 0; i < mapLength; i++) { + const inputCbor = cborReader.readEncodedValue(); + const outputCbor = cborReader.readEncodedValue(); + const utxo = Serialization.TransactionUnspentOutput.fromCore([ + Serialization.TransactionInput.fromCbor(inputCbor).toCore(), + Serialization.TransactionOutput.fromCbor(outputCbor).toCore(), + ]); + utxos.push(fromTxUnspentOutput(utxo)); + } + } + return utxos; +}; diff --git a/packages/mesh-core/package.json b/packages/mesh-core/package.json index ac44f6983..366d370b5 100644 --- a/packages/mesh-core/package.json +++ b/packages/mesh-core/package.json @@ -37,7 +37,8 @@ "@meshsdk/core-cst": "1.9.0-beta.102", "@meshsdk/provider": "1.9.0-beta.100", "@meshsdk/transaction": "1.9.0-beta.102", - "@meshsdk/wallet": "1.9.0-beta.102" + "@meshsdk/wallet": "1.9.0-beta.102", + "scalus": "^0.15.0" }, "prettier": "@meshsdk/configs/prettier", "publishConfig": { diff --git a/packages/mesh-core/src/utils/emulator.ts b/packages/mesh-core/src/utils/emulator.ts new file mode 100644 index 000000000..c51418daf --- /dev/null +++ b/packages/mesh-core/src/utils/emulator.ts @@ -0,0 +1,96 @@ +import { Emulator, SlotConfig as ScalusSlotConfig, SubmitResult } from "scalus"; + +import { + AccountInfo, + Asset, + AssetMetadata, + BlockInfo, + GovernanceProposalInfo, + IFetcher, + IFetcherOptions, + Protocol, + SlotConfig, + TransactionInfo, + UTxO, +} from "@meshsdk/common"; +import { cborMapToUtxos, utxosToCborMap } from "@meshsdk/core-cst"; +import { OfflineFetcher } from "@meshsdk/provider"; + +export class MeshEmulator extends Emulator implements IFetcher { + fetcher: OfflineFetcher; + + constructor(initialUtxos: UTxO[], slotConfig: SlotConfig) { + super( + Buffer.from(utxosToCborMap(initialUtxos), "hex"), + new ScalusSlotConfig( + slotConfig.zeroTime, + slotConfig.zeroSlot, + slotConfig.slotLength, + ), + ); + this.fetcher = new OfflineFetcher(); + this.fetcher.addUTxOs(initialUtxos); + } + fetchAccountInfo(address: string): Promise { + return this.fetcher.fetchAccountInfo(address); + } + fetchAddressUTxOs(address: string, asset?: string): Promise { + return this.fetcher.fetchAddressUTxOs(address, asset); + } + fetchAddressTxs( + address: string, + options?: IFetcherOptions, + ): Promise { + return this.fetcher.fetchAddressTxs(address, options); + } + fetchAssetAddresses( + asset: string, + ): Promise<{ address: string; quantity: string }[]> { + return this.fetcher.fetchAssetAddresses(asset); + } + fetchAssetMetadata(asset: string): Promise { + return this.fetcher.fetchAssetMetadata(asset); + } + fetchBlockInfo(hash: string): Promise { + return this.fetcher.fetchBlockInfo(hash); + } + fetchCollectionAssets( + policyId: string, + cursor?: number | string, + ): Promise<{ assets: Asset[]; next?: string | number | null }> { + return this.fetcher.fetchCollectionAssets(policyId, cursor); + } + fetchProtocolParameters(epoch: number): Promise { + return this.fetcher.fetchProtocolParameters(epoch); + } + fetchTxInfo(hash: string): Promise { + return this.fetcher.fetchTxInfo(hash); + } + fetchUTxOs(hash: string, index?: number): Promise { + return this.fetcher.fetchUTxOs(hash); + } + fetchGovernanceProposal( + txHash: string, + certIndex: number, + ): Promise { + return this.fetcher.fetchGovernanceProposal(txHash, certIndex); + } + get(url: string): Promise { + return this.fetcher.get(url); + } + + submitTxHex(txHex: string): SubmitResult { + const result = this.submitTx(Buffer.from(txHex, "hex")); + if (result.isSuccess) { + this.fetcher = new OfflineFetcher(); + this.fetcher.addUTxOs(this.getAllUtxosMesh()); + } + return result; + } + + getAllUtxosMesh() { + const allUtxos = this.getAllUtxos(); + const utxoList = allUtxos.map((u) => Buffer.from(u).toString("hex")); + return cborMapToUtxos(utxoList); + } +} diff --git a/packages/mesh-core/src/utils/serializer.ts b/packages/mesh-core/src/utils/serializer.ts index a7ff97c6b..f1e74925e 100644 --- a/packages/mesh-core/src/utils/serializer.ts +++ b/packages/mesh-core/src/utils/serializer.ts @@ -1,8 +1,5 @@ -import JSONBig from "json-bigint"; - import { BuilderData, - Data, DeserializedAddress, NativeScript, PlutusDataType, diff --git a/packages/mesh-provider/test/scalus/e2e.test.ts b/packages/mesh-provider/test/scalus/e2e.test.ts new file mode 100644 index 000000000..2a9b63b35 --- /dev/null +++ b/packages/mesh-provider/test/scalus/e2e.test.ts @@ -0,0 +1,647 @@ +import { + applyCborEncoding, + MeshTxBuilder, + resolveNativeScriptHash, + resolveNativeScriptHex, + resolvePaymentKeyHash, + resolveScriptHash, + NativeScript, +} from "@meshsdk/core"; +import { AppWallet } from "@meshsdk/wallet"; +import { ScalusProvider } from "@meshsdk/provider"; +import { Emulator, SlotConfig } from "scalus"; + +const TEST_MNEMONIC = [ + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", + "solution", +]; + +const alwaysSucceedCbor = applyCborEncoding( + "58340101002332259800a518a4d153300249011856616c696461746f722072657475726e65642066616c736500136564004ae715cd01", +); + +async function createTestSetup(lovelacePerAddress = 10_000_000_000n) { + const slotConfig = SlotConfig.preview; + const wallet = new AppWallet({ + networkId: 0, + key: { type: "mnemonic", words: TEST_MNEMONIC }, + }); + await wallet.init(); + const address = wallet.getPaymentAddress(); + + const emulator = Emulator.withAddresses( + [address], + slotConfig, + lovelacePerAddress, + ); + const currentSlot = slotConfig.timeToSlot(Date.now()); + emulator.setSlot(currentSlot); + + const provider = new ScalusProvider(emulator, slotConfig); + + const newTxBuilder = () => + new MeshTxBuilder({ + fetcher: provider, + submitter: provider, + evaluator: provider, + }); + + return { wallet, address, provider, emulator, slotConfig, newTxBuilder }; +} + +describe("ScalusProvider", () => { + describe("Basic payment lifecycle", () => { + it("should build, sign, submit and confirm a simple payment", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + expect(utxos.length).toBeGreaterThan(0); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "5000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash).toBeDefined(); + expect(txHash.length).toBe(64); + }); + + it("should reflect UTxO changes after submission", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxosBefore = await provider.fetchAddressUTxOs(address); + const totalBefore = utxosBefore.reduce( + (sum, u) => + sum + + BigInt(u.output.amount.find((a) => a.unit === "lovelace")!.quantity), + 0n, + ); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "3000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxosBefore) + .complete(); + + const signedTx = await wallet.signTx(txHex); + await provider.submitTx(signedTx); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const totalAfter = utxosAfter.reduce( + (sum, u) => + sum + + BigInt(u.output.amount.find((a) => a.unit === "lovelace")!.quantity), + 0n, + ); + + expect(utxosAfter.length).toBeGreaterThan(0); + // Total should decrease by fees + expect(totalAfter).toBeLessThan(totalBefore); + expect(totalAfter).toBeGreaterThan(totalBefore - 1_000_000n); + }); + + it("should chain multiple transactions", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos1 = await provider.fetchAddressUTxOs(address); + const txHex1 = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos1) + .complete(); + const signed1 = await wallet.signTx(txHex1); + const hash1 = await provider.submitTx(signed1); + + const utxos2 = await provider.fetchAddressUTxOs(address); + const txHex2 = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos2) + .complete(); + const signed2 = await wallet.signTx(txHex2); + const hash2 = await provider.submitTx(signed2); + + expect(hash1).not.toBe(hash2); + + const utxos3 = await provider.fetchAddressUTxOs(address); + expect(utxos3.length).toBeGreaterThan(0); + // Original UTxOs should be consumed + const utxo1Hashes = new Set(utxos1.map((u) => u.input.txHash)); + const utxo3Hashes = new Set(utxos3.map((u) => u.input.txHash)); + for (const h of utxo1Hashes) { + expect(utxo3Hashes.has(h)).toBe(false); + } + }); + }); + + describe("Native script minting", () => { + it("should mint tokens with a native script and verify UTxOs", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const keyHash = resolvePaymentKeyHash(address); + const nativeScript: NativeScript = { type: "sig", keyHash }; + const scriptCbor = resolveNativeScriptHex(nativeScript); + const policyId = resolveNativeScriptHash(nativeScript); + const tokenNameHex = Buffer.from("TestToken").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .mint("1000", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "1000" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const tokenUtxo = utxosAfter.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenUtxo).toBeDefined(); + expect( + tokenUtxo!.output.amount.find((a) => a.unit === unit)!.quantity, + ).toBe("1000"); + }); + + it("should mint and then burn tokens", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const keyHash = resolvePaymentKeyHash(address); + const nativeScript: NativeScript = { type: "sig", keyHash }; + const scriptCbor = resolveNativeScriptHex(nativeScript); + const policyId = resolveNativeScriptHash(nativeScript); + const tokenNameHex = Buffer.from("BurnToken").toString("hex"); + const unit = policyId + tokenNameHex; + + // Step 1: Mint + const utxos = await provider.fetchAddressUTxOs(address); + const mintTxHex = await newTxBuilder() + .mint("1000", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "1000" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedMint = await wallet.signTx(mintTxHex); + await provider.submitTx(signedMint); + + // Step 2: Burn half + const utxosAfterMint = await provider.fetchAddressUTxOs(address); + const tokenUtxo = utxosAfterMint.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenUtxo).toBeDefined(); + + const burnTxHex = await newTxBuilder() + .mint("-500", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txIn( + tokenUtxo!.input.txHash, + tokenUtxo!.input.outputIndex, + tokenUtxo!.output.amount, + tokenUtxo!.output.address, + ) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "500" }, + ]) + .changeAddress(address) + .selectUtxosFrom( + utxosAfterMint.filter( + (u) => + u.input.txHash !== tokenUtxo!.input.txHash || + u.input.outputIndex !== tokenUtxo!.input.outputIndex, + ), + ) + .complete(); + + const signedBurn = await wallet.signTx(burnTxHex); + await provider.submitTx(signedBurn); + + // Step 3: Verify remaining + const utxosFinal = await provider.fetchAddressUTxOs(address); + let totalTokens = 0n; + for (const u of utxosFinal) { + const tokenAsset = u.output.amount.find((a) => a.unit === unit); + if (tokenAsset) totalTokens += BigInt(tokenAsset.quantity); + } + expect(totalTokens).toBe(500n); + }); + }); + + describe("Plutus script evaluation", () => { + it("should evaluate and submit a plutus minting transaction", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const policyId = resolveScriptHash(alwaysSucceedCbor, "V3"); + const tokenNameHex = Buffer.from("PlutusToken").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .mintPlutusScriptV3() + .mint("100", policyId, tokenNameHex) + .mintRedeemerValue("") + .mintingScript(alwaysSucceedCbor) + .txInCollateral( + utxos[0]!.input.txHash, + utxos[0]!.input.outputIndex, + utxos[0]!.output.amount, + utxos[0]!.output.address, + ) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "100" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const tokenUtxo = utxosAfter.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenUtxo).toBeDefined(); + expect( + tokenUtxo!.output.amount.find((a) => a.unit === unit)!.quantity, + ).toBe("100"); + }); + + it("should evaluate a plutus spending transaction", async () => { + const { wallet, address, provider, newTxBuilder, slotConfig } = + await createTestSetup(); + + const policyId = resolveScriptHash(alwaysSucceedCbor, "V3"); + const tokenNameHex = Buffer.from("SpendTest").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + // Step 1: Mint tokens using plutus script + const mintTxHex = await newTxBuilder() + .mintPlutusScriptV3() + .mint("50", policyId, tokenNameHex) + .mintRedeemerValue("") + .mintingScript(alwaysSucceedCbor) + .txInCollateral( + utxos[0]!.input.txHash, + utxos[0]!.input.outputIndex, + utxos[0]!.output.amount, + utxos[0]!.output.address, + ) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "50" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedMint = await wallet.signTx(mintTxHex); + const mintHash = await provider.submitTx(signedMint); + expect(mintHash.length).toBe(64); + + // Step 2: Verify the minted tokens via fetchUTxOs + const mintedUtxos = await provider.fetchUTxOs(mintHash); + expect(mintedUtxos.length).toBeGreaterThan(0); + const tokenOutput = mintedUtxos.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenOutput).toBeDefined(); + }); + }); + + describe("Validity intervals", () => { + it("should build and submit a transaction with TTL", async () => { + const { wallet, address, provider, newTxBuilder, slotConfig } = + await createTestSetup(); + + const currentSlot = slotConfig.timeToSlot(Date.now()); + const ttlSlot = currentSlot + 300; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .invalidHereafter(ttlSlot) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + }); + + it("should build and submit with both validity bounds", async () => { + const { wallet, address, provider, newTxBuilder, slotConfig } = + await createTestSetup(); + + const currentSlot = slotConfig.timeToSlot(Date.now()); + const validFrom = currentSlot - 10; + const validTo = currentSlot + 300; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .invalidBefore(validFrom) + .invalidHereafter(validTo) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + }); + + it("should reject an expired transaction", async () => { + const { wallet, address, provider, newTxBuilder, slotConfig } = + await createTestSetup(); + + const currentSlot = slotConfig.timeToSlot(Date.now()); + const expiredSlot = currentSlot - 100; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .invalidHereafter(expiredSlot) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + await expect(provider.submitTx(signedTx)).rejects.toThrow(); + }); + + it("should reject a transaction before validity start", async () => { + const { wallet, address, provider, newTxBuilder, slotConfig } = + await createTestSetup(); + + const currentSlot = slotConfig.timeToSlot(Date.now()); + const futureStart = currentSlot + 300; + const futureTtl = currentSlot + 600; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .invalidBefore(futureStart) + .invalidHereafter(futureTtl) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + await expect(provider.submitTx(signedTx)).rejects.toThrow(); + }); + }); + + describe("Transaction composition", () => { + it("should build a transaction with multiple outputs", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .txOut(address, [{ unit: "lovelace", quantity: "3000000" }]) + .txOut(address, [{ unit: "lovelace", quantity: "4000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const amounts = utxosAfter.map((u) => + BigInt(u.output.amount.find((a) => a.unit === "lovelace")!.quantity), + ); + expect(amounts.filter((a) => a === 2_000_000n).length).toBeGreaterThan(0); + expect(amounts.filter((a) => a === 3_000_000n).length).toBeGreaterThan(0); + expect(amounts.filter((a) => a === 4_000_000n).length).toBeGreaterThan(0); + }); + + it("should combine minting and payment in a single transaction", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const keyHash = resolvePaymentKeyHash(address); + const nativeScript: NativeScript = { type: "sig", keyHash }; + const scriptCbor = resolveNativeScriptHex(nativeScript); + const policyId = resolveNativeScriptHash(nativeScript); + const tokenNameHex = Buffer.from("ComboToken").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .mint("777", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txOut(address, [ + { unit: "lovelace", quantity: "5000000" }, + { unit, quantity: "777" }, + ]) + .txOut(address, [{ unit: "lovelace", quantity: "3000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + + const utxosAfter = await provider.fetchAddressUTxOs(address); + const tokenUtxo = utxosAfter.find((u) => + u.output.amount.some((a) => a.unit === unit), + ); + expect(tokenUtxo).toBeDefined(); + expect( + tokenUtxo!.output.amount.find((a) => a.unit === unit)!.quantity, + ).toBe("777"); + }); + + it("should build a transaction with metadata", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .metadataValue(674, "Hello from ScalusProvider test") + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + expect(txHash.length).toBe(64); + }); + }); + + describe("Error handling", () => { + it("should reject a transaction with insufficient funds", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(5_000_000n); + + const utxos = await provider.fetchAddressUTxOs(address); + + await expect( + newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "100000000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(), + ).rejects.toThrow(); + }); + + it("should reject submitting an unsigned transaction", async () => { + const { address, provider, newTxBuilder } = await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "2000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + await expect(provider.submitTx(txHex)).rejects.toThrow(); + }); + + it("should throw on unsupported fetcher methods", async () => { + const { provider } = await createTestSetup(); + + await expect(provider.fetchAccountInfo("addr_test1...")).rejects.toThrow( + "not supported", + ); + await expect(provider.fetchBlockInfo("abc")).rejects.toThrow( + "not supported", + ); + await expect(provider.fetchTxInfo("abc")).rejects.toThrow( + "not supported", + ); + }); + }); + + describe("UTxO querying", () => { + it("should fetch UTxOs by transaction hash after submission", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .txOut(address, [{ unit: "lovelace", quantity: "7000000" }]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + const txHash = await provider.submitTx(signedTx); + + const txUtxos = await provider.fetchUTxOs(txHash); + expect(txUtxos.length).toBeGreaterThan(0); + expect(txUtxos.every((u) => u.input.txHash === txHash)).toBe(true); + + const has7Ada = txUtxos.some((u) => + u.output.amount.some( + (a) => a.unit === "lovelace" && a.quantity === "7000000", + ), + ); + expect(has7Ada).toBe(true); + }); + + it("should filter UTxOs by asset unit", async () => { + const { wallet, address, provider, newTxBuilder } = + await createTestSetup(); + + const keyHash = resolvePaymentKeyHash(address); + const nativeScript: NativeScript = { type: "sig", keyHash }; + const scriptCbor = resolveNativeScriptHex(nativeScript); + const policyId = resolveNativeScriptHash(nativeScript); + const tokenNameHex = Buffer.from("FilterToken").toString("hex"); + const unit = policyId + tokenNameHex; + + const utxos = await provider.fetchAddressUTxOs(address); + + const txHex = await newTxBuilder() + .mint("200", policyId, tokenNameHex) + .mintingScript(scriptCbor) + .txOut(address, [ + { unit: "lovelace", quantity: "2000000" }, + { unit, quantity: "200" }, + ]) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + const signedTx = await wallet.signTx(txHex); + await provider.submitTx(signedTx); + + const allUtxos = await provider.fetchAddressUTxOs(address); + const filteredUtxos = await provider.fetchAddressUTxOs(address, unit); + + expect(filteredUtxos.length).toBeLessThan(allUtxos.length); + expect(filteredUtxos.length).toBe(1); + expect( + filteredUtxos[0]!.output.amount.find((a) => a.unit === unit)!.quantity, + ).toBe("200"); + }); + }); +});