diff --git a/.github/ISSUE_TEMPLATE/-bug.md b/.github/ISSUE_TEMPLATE/-bug.md new file mode 100644 index 00000000..e2a142ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/-bug.md @@ -0,0 +1,32 @@ +--- +name: "[BUG]: Short description of the problem" +about: Create a report to help us improve +title: "" +labels: "" +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Station (please complete the following information):** + +- OS: [e.g. Windows, Ubuntu] +- Version [e.g. 10, 11, 22.02] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/-docs.md b/.github/ISSUE_TEMPLATE/-docs.md new file mode 100644 index 00000000..e3bf5da0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/-docs.md @@ -0,0 +1,16 @@ +--- +name: "[DOCS] Short description of the documentation issue" +about: Suggest documentation improvements +title: "" +labels: "" +assignees: "" +--- + +**Describe the issue with the documentation** +A clear and concise description of what the issue is. + +**Suggest a solution** +Describe how the documentation can be improved or corrected. + +**Additional context** +Add any other context, references, or screenshots that might help clarify the issue. diff --git a/.github/ISSUE_TEMPLATE/-feature.md b/.github/ISSUE_TEMPLATE/-feature.md new file mode 100644 index 00000000..7c1fb5c2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/-feature.md @@ -0,0 +1,19 @@ +--- +name: "[FEATURE] Short description of the feature" +about: Suggest an idea for this project +title: "" +labels: "" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request.template.md b/.github/pull_request.template.md new file mode 100644 index 00000000..18f9cdbd --- /dev/null +++ b/.github/pull_request.template.md @@ -0,0 +1,53 @@ +Thank you for contributing to Mesh! We appreciate your effort and dedication to improving this project. To ensure that your contribution is in line with the project's guidelines and can be reviewed efficiently, please fill out the template below. + +Remember to follow our [Contributing Guide](CONTRIBUTING.md) before submitting your pull request. + +## Summary + +> Please provide a brief, concise summary of the changes in your pull request. Explain the problem you are trying to solve and the solution you have implemented. + +## Affect components + +> Please indicate which part of the Mesh Repo + +- [ ] `@meshsdk/common` +- [ ] `@meshsdk/contract` +- [ ] `@meshsdk/core` +- [ ] `@meshsdk/core-csl` +- [ ] `@meshsdk/core-cst` +- [ ] `@meshsdk/provider` +- [ ] `@meshsdk/react` +- [ ] `@meshsdk/transaction` +- [ ] `@meshsdk/wallet` +- [ ] Mesh playground (i.e. ) +- [ ] Mesh CLI + +## Type of Change + +> Please mark the relevant option(s) for your pull request: + +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] Code refactoring (improving code quality without changing its behavior) +- [ ] Documentation update (adding or updating documentation related to the project) + +## Related Issues + +> Please add the related issue here if any, e.g.: +> +> - Issue - [#329](https://github.com/MeshJS/mesh/issues/329) + +## Checklist + +> Please ensure that your pull request meets the following criteria: + +- [ ] My code is appropriately commented and includes relevant documentation, if necessary +- [ ] I have added tests to cover my changes, if necessary +- [ ] I have updated the documentation, if necessary +- [ ] All new and existing tests pass (i.e. `npm run test`) +- [ ] The build is pass (i.e. `npm run build`) + +## Additional Information + +> If you have any additional information or context to provide, such as screenshots, relevant issues, or other details, please include them here. diff --git a/packages/mesh-provider/src/offline/offline-fetcher.ts b/packages/mesh-provider/src/offline/offline-fetcher.ts new file mode 100644 index 00000000..0dcceebd --- /dev/null +++ b/packages/mesh-provider/src/offline/offline-fetcher.ts @@ -0,0 +1,519 @@ +import { + AccountInfo, + Asset, + AssetMetadata, + BlockInfo, + fromUTF8, + IFetcher, + Protocol, + SUPPORTED_HANDLES, + TransactionInfo, + UTxO +} from "@meshsdk/common"; +import { parseHttpError } from "../utils"; + +type AssetAddress = { + address: string; + quantity: string; +} + +export class OfflineFetcher implements IFetcher { + private accounts: Record = {}; + private utxos: Record = {}; + private assetAddresses: Record = {}; + private assetMetadata: Record = {}; + private blocks: Record = {}; + private collections: Record = {}; + private protocolParameters: Record = {}; + private transactions: Record = {}; + + private paginate(items: T[], cursor?: number | string, pageSize: number = 20): { paginatedItems: T[], nextCursor?: number } { + const startIndex = cursor != null ? parseInt(String(cursor), 10) : 0; + const paginatedItems = items.slice(startIndex, startIndex + pageSize); + const nextCursor = (startIndex + pageSize) < items.length ? startIndex + pageSize : undefined; + return { paginatedItems, nextCursor }; + } + + async fetchAccountInfo(address: string): Promise { + const account = this.accounts[address]; + if (!account) throw new Error(`Account not found: ${address}`); + return account; + } + + async fetchAddressUTxOs(address: string, asset?: string): Promise { + const addressUtxos = this.utxos[address] || []; + return asset ? addressUtxos.filter(utxo => utxo.output.amount.some(a => a.unit === asset)) : addressUtxos; + } + + async fetchAssetAddresses(asset: string): Promise { + const addresses = this.assetAddresses[asset]; + if (!addresses) throw new Error(`Asset addresses not found: ${asset}`); + return addresses; + } + + async fetchAssetMetadata(asset: string): Promise { + const metadata = this.assetMetadata[asset]; + if (!metadata) throw new Error(`Asset metadata not found: ${asset}`); + return metadata; + } + + async fetchBlockInfo(hash: string): Promise { + const block = this.blocks[hash]; + if (!block) throw new Error(`Block not found: ${hash}`); + return block; + } + + async fetchCollectionAssets(policyId: string, cursor?: number | string): Promise<{ assets: Asset[], next?: string | number }> { + const assets = this.collections[policyId]; + if (!assets) throw new Error(`Collection not found: ${policyId}`); + + if (cursor && !OfflineFetcher.isIntegerString(String(cursor))) { + throw new Error("Invalid cursor: must be a string of digits"); + } + + const { paginatedItems, nextCursor } = this.paginate(assets, cursor); + return { assets: paginatedItems, next: nextCursor }; + } + + async fetchHandle(handle: string): Promise { + try { + const assetName = fromUTF8(handle.replace("$", "")); + const handleAsset = `${SUPPORTED_HANDLES[1]}000de140${assetName}`; + return await this.fetchAssetMetadata(handleAsset); + } catch (error) { + throw parseHttpError(error); + } + } + + async fetchHandleAddress(handle: string): Promise { + const assetName = fromUTF8(handle.replace("$", "")); + const policyId = SUPPORTED_HANDLES[1]; + const addresses = await this.fetchAssetAddresses(`${policyId}${assetName}`); + + const address = addresses[0]?.address; + if (!address) { + throw new Error(`No addresses found for handle: ${handle}`); + } + + return address; + } + + async fetchProtocolParameters(epoch: number): Promise { + const parameters = this.protocolParameters[epoch]; + if (!parameters) throw new Error(`Protocol parameters not found for epoch: ${epoch}`); + return parameters; + } + + async fetchTxInfo(hash: string): Promise { + const transaction = this.transactions[hash]; + if (!transaction) throw new Error(`Transaction not found: ${hash}`); + return transaction; + } + + async fetchUTxOs(hash: string): Promise { + const utxos = Object.values(this.utxos).flat().filter(utxo => utxo.input.txHash === hash); + if (!utxos.length) throw new Error(`No UTxOs found for transaction hash: ${hash}`); + return utxos; + } + + async get(url: string): Promise { + throw new Error("Method not implemented in OfflineFetcher."); + } + + toJSON(): string { + return JSON.stringify({ + accounts: this.accounts, + utxos: this.utxos, + assetAddresses: this.assetAddresses, + assetMetadata: this.assetMetadata, + blocks: this.blocks, + collections: this.collections, + protocolParameters: this.protocolParameters, + transactions: this.transactions + }); + } + + static fromJSON(json: string): OfflineFetcher { + const data = JSON.parse(json); + const fetcher = new OfflineFetcher(); + + Object.entries(data.accounts || {}).forEach(([address, info]) => + fetcher.addAccount(address, info as AccountInfo)); + + Object.entries(data.utxos || {}).forEach(([address, utxos]) => + fetcher.addUTxOs(utxos as UTxO[])); + + Object.entries(data.assetAddresses || {}).forEach(([asset, addresses]) => + fetcher.addAssetAddresses(asset, addresses as AssetAddress[])); + + Object.entries(data.assetMetadata || {}).forEach(([asset, metadata]) => + fetcher.addAssetMetadata(asset, metadata as AssetMetadata)); + + Object.entries(data.blocks || {}).forEach(([_, info]) => + fetcher.addBlock(info as BlockInfo)); + + Object.entries(data.collections || {}).forEach(([policyId, assets]) => + fetcher.addCollectionAssets(assets as Asset[])); + + Object.entries(data.protocolParameters || {}).forEach(([_, params]) => + fetcher.addProtocolParameters(params as Protocol)); + + Object.entries(data.transactions || {}).forEach(([_, info]) => + fetcher.addTransaction(info as TransactionInfo)); + + return fetcher; + } + + private static isValidHex(str: string, length?: number): boolean { + if (length !== undefined && str.length !== length) { + return false; + } + return /^[0-9a-fA-F]+$/.test(str); + } + + private static isValidAddress(address: string): boolean { + return ( + OfflineFetcher.isValidBech32Address(address) || + OfflineFetcher.isValidBase58(address) + ); + } + + private static isValidBase58(input: string): boolean { + // Base58 character set (Bitcoin alphabet) + const base58Regex = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/; + // Check that input matches Base58 character set + if (!base58Regex.test(input)) { + return false; + } + // Additional checks can be added here, such as length or checksum validation + return true; + } + + private static isValidBech32(input: string, prefix: string): boolean { + // Check if the input is all lowercase or all uppercase + if (input !== input.toLowerCase() && input !== input.toUpperCase()) { + return false; + } + + // Bech32 regex pattern for the given prefix + const pattern = new RegExp(`^${prefix}1[02-9ac-hj-np-z]+$`, 'i'); + return pattern.test(input); + } + + private static isValidBech32Address(address: string): boolean { + return OfflineFetcher.isValidBech32(address, '(addr|addr_test)'); + } + + private static isValidBech32Pool(poolId: string): boolean { + return OfflineFetcher.isValidBech32(poolId, 'pool'); + } + + private static isValidBech32VrfVk(vrfKey: string): boolean { + return OfflineFetcher.isValidBech32(vrfKey, 'vrf_vk'); + } + + + private static isIntegerString(str: string): boolean { + return /^\d+$/.test(str); + } + + private static isValidAssetOrLovelace(asset: string): boolean { + if (asset === 'lovelace') { + return true; + } + if (asset.length < 56) { + return false; + } + return OfflineFetcher.isValidHex(asset); + } + + addAccount(address: string, accountInfo: AccountInfo): void { + if (!OfflineFetcher.isValidAddress(address)) { + throw new Error("Invalid address: must be a valid Bech32 or Base58 address"); + } + + if (accountInfo.poolId !== undefined) { + if (!OfflineFetcher.isValidBech32Pool(accountInfo.poolId)) { + throw new Error("Invalid 'poolId': must be a valid Bech32 pool address"); + } + } + + if (!OfflineFetcher.isIntegerString(accountInfo.balance)) { + throw new Error("Invalid 'balance': must be a string of digits"); + } + + if (!OfflineFetcher.isIntegerString(accountInfo.rewards)) { + throw new Error("Invalid 'rewards': must be a string of digits"); + } + + if (!OfflineFetcher.isIntegerString(accountInfo.withdrawals)) { + throw new Error("Invalid 'withdrawals': must be a string of digits"); + } + + this.accounts[address] = accountInfo; + } + + addUTxOs(utxos: UTxO[]): void { + if (!Array.isArray(utxos) || utxos.length === 0) { + throw new Error("Invalid utxos: must be a non-empty array"); + } + + utxos.forEach((utxo, index) => { + if (!Number.isInteger(utxo.input.outputIndex) || utxo.input.outputIndex < 0) { + throw new Error(`Invalid outputIndex for UTxO at index ${index}: must be a non-negative integer`); + } + if (!OfflineFetcher.isValidHex(utxo.input.txHash, 64)) { + throw new Error(`Invalid txHash for UTxO at index ${index}: must be a 64-character hexadecimal string`); + } + + if (!OfflineFetcher.isValidAddress(utxo.output.address)) { + throw new Error(`Invalid address in output for UTxO at index ${index}: must be a valid Bech32 or Base58 address`); + } + if (!Array.isArray(utxo.output.amount) || utxo.output.amount.length === 0) { + throw new Error(`Invalid amount for UTxO at index ${index}: must be a non-empty array of assets`); + } + + utxo.output.amount.forEach((asset, assetIndex) => { + if(!OfflineFetcher.isValidAssetOrLovelace(asset.unit)) { + throw new Error(`Invalid unit for asset at index ${assetIndex} in UTxO at index ${index}`); + } + if (!OfflineFetcher.isIntegerString(asset.quantity)) { + throw new Error(`Invalid quantity for asset at index ${assetIndex} in UTxO at index ${index}: must be a string of digits`); + } + }); + + if (utxo.output.dataHash !== undefined && !OfflineFetcher.isValidHex(utxo.output.dataHash, 64)) { + throw new Error(`Invalid dataHash for UTxO at index ${index}: must be a 64-character hexadecimal string or undefined`); + } + if (utxo.output.plutusData !== undefined && !OfflineFetcher.isValidHex(utxo.output.plutusData)) { + throw new Error(`Invalid plutusData for UTxO at index ${index}: must be a hexadecimal string or undefined`); + } + if (utxo.output.scriptRef !== undefined && !OfflineFetcher.isValidHex(utxo.output.scriptRef)) { + throw new Error(`Invalid scriptRef for UTxO at index ${index}: must be a hexadecimal string or undefined`); + } + if (utxo.output.scriptHash !== undefined && !OfflineFetcher.isValidHex(utxo.output.scriptHash, 56)) { + throw new Error(`Invalid scriptHash for UTxO at index ${index}: must be a 56-character hexadecimal string or undefined`); + } + }); + + for (const utxo of utxos) { + if (!this.utxos[utxo.output.address]) { + this.utxos[utxo.output.address] = []; + } + this.utxos[utxo.output.address]!.push(utxo); + } + } + + addAssetAddresses(asset: string, addresses: AssetAddress[]): void { + if (!OfflineFetcher.isValidHex(asset)) { + throw new Error("Invalid asset: must be a hex string"); + } + if (addresses.length === 0) { + throw new Error("Invalid addresses: must be a non-empty array"); + } + addresses.forEach((item, index) => { + if (!OfflineFetcher.isValidAddress(item.address)) { + throw new Error(`Invalid 'address' field at index ${index}, should be bech32 string`); + } + if (!OfflineFetcher.isIntegerString(item.quantity)) { + throw new Error(`Invalid 'quantity' field at index ${index}, should be a string of digits`); + } + }); + this.assetAddresses[asset] = addresses; + } + + addAssetMetadata(asset: string, metadata: AssetMetadata): void { + if (asset.length < 56) { + throw new Error(`Invalid asset ${asset}: must be a string longer than 56 characters`); + } + if (!OfflineFetcher.isValidHex(asset)) { + throw new Error("Invalid asset: must be a hex string"); + } + + if (typeof metadata !== 'object' || metadata === null) { + throw new Error("Invalid metadata object"); + } + this.assetMetadata[asset] = metadata; + } + + addCollectionAssets(assets: Asset[]): void { + if (!Array.isArray(assets) || assets.length === 0) { + throw new Error("Invalid assets: must be a non-empty array"); + } + + const groupedAssets: { [policyId: string]: Asset[] } = {}; + + assets.forEach((asset, index) => { + if (asset.unit.length < 56) { + throw new Error(`Invalid unit for asset at index ${index}: must be a string longer than 56 characters`); + } + + if(!OfflineFetcher.isValidHex(asset.unit)) { + throw new Error(`Invalid unit for asset at index ${index}: must be a hexadecimal string`); + } + + const policyId = asset.unit.slice(0, 56); + + if (!OfflineFetcher.isValidHex(policyId, 56)) { + throw new Error(`Invalid policyId in asset unit at index ${index}: must be a 56-character hexadecimal string`); + } + + if (!OfflineFetcher.isIntegerString(asset.quantity)) { + throw new Error(`Invalid quantity for asset at index ${index}: must be a string of digits`); + } + + if (!groupedAssets[policyId]) { + groupedAssets[policyId] = []; + } + groupedAssets[policyId].push(asset); + }); + + for (const [policyId, policyAssets] of Object.entries(groupedAssets)) { + if (!this.collections[policyId]) { + this.collections[policyId] = []; + } + this.collections[policyId] = this.collections[policyId].concat(policyAssets); + } + } + + addProtocolParameters(parameters: Protocol): void { + if (parameters.epoch < 0 || !Number.isInteger(parameters.epoch)) { + throw new Error("Invalid epoch: must be a non-negative integer"); + } + + if (parameters.minFeeA < 0 || !Number.isInteger(parameters.minFeeA)) { + throw new Error("Invalid 'minFeeA': must be a non-negative integer"); + } + if (parameters.minFeeB < 0 || !Number.isInteger(parameters.minFeeB)) { + throw new Error("Invalid 'minFeeB': must be a non-negative integer"); + } + if (parameters.maxBlockSize <= 0 || !Number.isInteger(parameters.maxBlockSize)) { + throw new Error("Invalid 'maxBlockSize': must be a positive integer"); + } + if (parameters.maxTxSize <= 0 || !Number.isInteger(parameters.maxTxSize)) { + throw new Error("Invalid 'maxTxSize': must be a positive integer"); + } + if (parameters.maxBlockHeaderSize <= 0 || !Number.isInteger(parameters.maxBlockHeaderSize)) { + throw new Error("Invalid 'maxBlockHeaderSize': must be a positive integer"); + } + if (parameters.keyDeposit < 0 || !Number.isInteger(parameters.keyDeposit)) { + throw new Error("Invalid 'keyDeposit': must be a non-negative integer"); + } + if (parameters.poolDeposit < 0 || !Number.isInteger(parameters.poolDeposit)) { + throw new Error("Invalid 'poolDeposit': must be a non-negative integer"); + } + if (parameters.decentralisation < 0 || parameters.decentralisation > 1) { + throw new Error("Invalid 'decentralisation': must be between 0 and 1"); + } + if (parameters.priceMem < 0) { + throw new Error("Invalid 'priceMem': must be non-negative"); + } + if (parameters.priceStep < 0) { + throw new Error("Invalid 'priceStep': must be non-negative"); + } + if (parameters.maxValSize < 0 || !Number.isInteger(parameters.maxValSize)) { + throw new Error("Invalid 'maxValSize': must be a non-negative integer"); + } + if (parameters.collateralPercent < 0) { + throw new Error("Invalid 'collateralPercent': must be a non-negative integer"); + } + if (parameters.maxCollateralInputs < 0 || !Number.isInteger(parameters.maxCollateralInputs)) { + throw new Error("Invalid 'maxCollateralInputs': must be a non-negative integer"); + } + if (parameters.coinsPerUtxoSize < 0) { + throw new Error("Invalid 'coinsPerUtxoSize': must be non-negative"); + } + if (parameters.minFeeRefScriptCostPerByte < 0) { + throw new Error("Invalid 'minFeeRefScriptCostPerByte': must be non-negative"); + } + + if (!OfflineFetcher.isIntegerString(parameters.minPoolCost)) { + throw new Error("Invalid 'minPoolCost': must be a string of digits"); + } + if (!OfflineFetcher.isIntegerString(parameters.maxTxExMem)) { + throw new Error("Invalid 'maxTxExMem': must be a string of digits"); + } + if (!OfflineFetcher.isIntegerString(parameters.maxTxExSteps)) { + throw new Error("Invalid 'maxTxExSteps': must be a string of digits"); + } + if (!OfflineFetcher.isIntegerString(parameters.maxBlockExMem)) { + throw new Error("Invalid 'maxBlockExMem': must be a string of digits"); + } + if (!OfflineFetcher.isIntegerString(parameters.maxBlockExSteps)) { + throw new Error("Invalid 'maxBlockExSteps': must be a string of digits"); + } + + this.protocolParameters[parameters.epoch] = parameters; + } + + addTransaction(txInfo: TransactionInfo): void { + if (!OfflineFetcher.isValidHex(txInfo.hash, 64)) { + throw new Error("Invalid transaction hash: must be a 64-character hexadecimal string"); + } + if (!Number.isInteger(txInfo.index) || txInfo.index < 0) { + throw new Error("Invalid 'index': must be a non-negative integer"); + } + if (!OfflineFetcher.isValidHex(txInfo.block, 64)) { + throw new Error("Invalid 'block': must be a 64-character hexadecimal string"); + } + if (!OfflineFetcher.isIntegerString(txInfo.slot)) { + throw new Error("Invalid 'slot': must be a string of digits"); + } + if (!OfflineFetcher.isIntegerString(txInfo.fees)) { + throw new Error("Invalid 'fees': must be a string of digits"); + } + if (!Number.isInteger(txInfo.size) || txInfo.size <= 0) { + throw new Error("Invalid 'size': must be a positive integer"); + } + if (!/^-?\d+$/.test(txInfo.deposit)) { + throw new Error("Invalid 'deposit': must be a string representing an integer (positive or negative)"); + } + if (txInfo.invalidBefore !== "" && !OfflineFetcher.isIntegerString(txInfo.invalidBefore)) { + throw new Error("Invalid 'invalidBefore': must be a string of digits or empty string"); + } + if (txInfo.invalidAfter !== "" && !OfflineFetcher.isIntegerString(txInfo.invalidAfter)) { + throw new Error("Invalid 'invalidAfter': must be a string of digits or empty string"); + } + this.transactions[txInfo.hash] = txInfo; + } + + addBlock(blockInfo: BlockInfo): void { + if (!OfflineFetcher.isValidHex(blockInfo.hash, 64)) { + throw new Error("Invalid block hash: must be a 64-character hexadecimal string"); + } + if (!Number.isInteger(blockInfo.time) || blockInfo.time < 0) { + throw new Error("Invalid 'time': must be a non-negative integer"); + } + if (!OfflineFetcher.isIntegerString(blockInfo.slot)) { + throw new Error("Invalid 'slot': must be a string of digits"); + } + if (!Number.isInteger(blockInfo.epoch) || blockInfo.epoch < 0) { + throw new Error("Invalid 'epoch': must be a non-negative integer"); + } + if (!OfflineFetcher.isIntegerString(blockInfo.epochSlot)) { + throw new Error("Invalid 'epochSlot': must be a string of digits"); + } + if (!OfflineFetcher.isValidBech32Pool(blockInfo.slotLeader)) { + throw new Error("Invalid 'slotLeader': must be a bech32 string with pool prefix"); + } + if (!Number.isInteger(blockInfo.size) || blockInfo.size <= 0) { + throw new Error("Invalid 'size': must be a positive integer"); + } + if (!Number.isInteger(blockInfo.txCount) || blockInfo.txCount < 0) { + throw new Error("Invalid 'txCount': must be a non-negative integer"); + } + if (!OfflineFetcher.isIntegerString(blockInfo.output)) { + throw new Error("Invalid 'output': must be a string of digits"); + } + if (!OfflineFetcher.isValidHex(blockInfo.operationalCertificate, 64)) { + throw new Error("Invalid 'operationalCertificate': must be a 64-character hexadecimal string"); + } + if (!OfflineFetcher.isValidHex(blockInfo.previousBlock, 64)) { + throw new Error("Invalid 'previousBlock': must be a 64-character hexadecimal string"); + } + if (!OfflineFetcher.isValidBech32VrfVk(blockInfo.VRFKey)) { + throw new Error("Invalid 'VRFKey': must be a bech32 string with vrf_vk1 prefix"); + } + this.blocks[blockInfo.hash] = blockInfo; + } +} diff --git a/packages/mesh-provider/test/offline/fetcher.test.ts b/packages/mesh-provider/test/offline/fetcher.test.ts new file mode 100644 index 00000000..731f2883 --- /dev/null +++ b/packages/mesh-provider/test/offline/fetcher.test.ts @@ -0,0 +1,768 @@ +import { + AccountInfo, + Asset, + AssetMetadata, + BlockInfo, + Protocol, + TransactionInfo, + UTxO, +} from "@meshsdk/common"; +import { OfflineFetcher } from "../../src"; + +describe("OfflineFetcher", () => { + let fetcher: OfflineFetcher; + + beforeEach(() => { + fetcher = new OfflineFetcher(); + }); + + const validBech32Address = "addr_test1qrhsnfvaqnd8r7dm9f9c5fscyfqmqzptyrn63dzrgvfw6p7vxwdrt70qlcpeeagscasafhffqsxy36t90ldv06wqrk2qkdr4hz"; + const validBase58Address= "Ae2tdPwUPEZ4YjgvykNpoFeYUxoyhNj2kg8KfKWN2FizsSpLUPv68MpTVDo" + const validPoolId = "pool1v0hm27ywsufus3xl6v4jayw7rccflmll642lsf7vnmskgtvnnx7"; + const validVrfVk = "vrf_vk1qthm27ywsufus3xl6v4jayw7rccflmll642lsf7vnmskgqgzqvzqtwha9s" + const validTxHash = "0443456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const validBlockHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + const validAsset = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const validPolicyId = "0123456789abcdef0123456789abcdef0123456789abcdef01234567"; + const validEpoch = 100; + + const sampleAccountInfo: AccountInfo = { + active: true, + balance: "1000000", + poolId: validPoolId, + rewards: "5000", + withdrawals: "0", + }; + + const sampleUTxO: UTxO = { + input: { + txHash: validTxHash, + outputIndex: 0, + }, + output: { + address: validBech32Address, + amount: [{ unit: "lovelace", quantity: "1000000" }], + dataHash: undefined, + }, + }; + + const sampleAssetMetadata: AssetMetadata = { + name: "Sample Token", + description: "A sample token for testing", + ticker: "IDKTICKER", + url: "https://example.com", + logo: null, + decimals: 0, + unit: validAsset, + }; + + const sampleBlockInfo: BlockInfo = { + hash: validBlockHash, + time: 1638316800, + slot: "50000000", + epoch: 290, + epochSlot: "200000", + slotLeader: validPoolId, + size: 500, + txCount: 10, + output: "1000000000", + fees: "500000", + previousBlock: "bbcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + nextBlock: "bbcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef", + confirmations: 10, + operationalCertificate: validTxHash, + VRFKey: validVrfVk, + }; + + const sampleProtocolParameters: Protocol = { + epoch: validEpoch, + minFeeA: 44, + minFeeB: 155381, + maxBlockSize: 65536, + maxTxSize: 16384, + maxBlockHeaderSize: 1100, + keyDeposit: 2000000, + poolDeposit: 500000000, + decentralisation: 0.5, + minPoolCost: "340000000", + priceMem: 0.0577, + priceStep: 0.0000721, + maxTxExMem: "10000000", + maxTxExSteps: "10000000000", + maxBlockExMem: "50000000", + maxBlockExSteps: "40000000000", + maxValSize: 5000, + collateralPercent: 150, + maxCollateralInputs: 3, + coinsPerUtxoSize: 4310, + minFeeRefScriptCostPerByte: 1000, + }; + + const sampleTransactionInfo: TransactionInfo = { + hash: validTxHash, + block: validBlockHash, + index: 0, + fees: "500000", + deposit: "0", + size: 200, + invalidBefore: "", + invalidAfter: "", + slot: "50000000", + }; + + const sampleAssetAddress = { + address: validBech32Address, + quantity: "1000", + }; + + const sampleAsset: Asset = { + unit: validAsset, + quantity: "1000", + }; + + describe("addAccount", () => { + it("should add a valid Bech32 account", () => { + expect(() => fetcher.addAccount(validBech32Address, sampleAccountInfo)).not.toThrow(); + }); + + it("should add a valid Base58 account", () => { + const accountInfoBase58 = { ...sampleAccountInfo, address: validBase58Address }; + expect(() => fetcher.addAccount(validBase58Address, accountInfoBase58)).not.toThrow(); + }); + + it("should throw an error for invalid address", () => { + const invalidAddress = "asasdsadasd44499((" + expect(() => fetcher.addAccount(invalidAddress, sampleAccountInfo)).toThrowError( + "Invalid address: must be a valid Bech32 or Base58 address" + ); + }); + }); + + describe("fetchAccountInfo", () => { + it("should fetch account info after adding it", async () => { + fetcher.addAccount(validBech32Address, sampleAccountInfo); + const accountInfo = await fetcher.fetchAccountInfo(validBech32Address); + expect(accountInfo).toEqual(sampleAccountInfo); + }); + + it("should throw an error if account info is not found", async () => { + await expect(fetcher.fetchAccountInfo(validBech32Address)).rejects.toThrowError( + `Account not found: ${validBech32Address}` + ); + }); + }); + + describe("addUTxOs", () => { + it("should add valid UTxOs", () => { + expect(() => fetcher.addUTxOs([sampleUTxO])).not.toThrow(); + }); + + it("should throw an error for invalid UTxOs", () => { + const invalidUTxO = { ...sampleUTxO, input: { ...sampleUTxO.input, txHash: "invalid_hash" } }; + expect(() => fetcher.addUTxOs([invalidUTxO])).toThrowError( + "Invalid txHash for UTxO at index 0: must be a 64-character hexadecimal string" + ); + }); + }); + + describe("fetchAddressUTxOs", () => { + it("should fetch UTxOs for an address", async () => { + fetcher.addUTxOs([sampleUTxO]); + const utxos = await fetcher.fetchAddressUTxOs(validBech32Address); + expect(utxos).toEqual([sampleUTxO]); + }); + + it("should return an empty array if no UTxOs are found", async () => { + const utxos = await fetcher.fetchAddressUTxOs(validBech32Address); + expect(utxos).toEqual([]); + }); + }); + + describe("addAssetMetadata", () => { + it("should add valid asset metadata", () => { + expect(() => fetcher.addAssetMetadata(validAsset, sampleAssetMetadata)).not.toThrow(); + }); + + it("should throw an error for invalid asset", () => { + const invalidAsset = "short_asset"; + expect(() => fetcher.addAssetMetadata(invalidAsset, sampleAssetMetadata)).toThrowError( + `Invalid asset ${invalidAsset}: must be a string longer than 56 characters` + ); + }); + }); + + describe("fetchAssetMetadata", () => { + it("should fetch asset metadata after adding it", async () => { + fetcher.addAssetMetadata(validAsset, sampleAssetMetadata); + const metadata = await fetcher.fetchAssetMetadata(validAsset); + expect(metadata).toEqual(sampleAssetMetadata); + }); + + it("should throw an error if asset metadata is not found", async () => { + await expect(fetcher.fetchAssetMetadata(validAsset)).rejects.toThrowError( + `Asset metadata not found: ${validAsset}` + ); + }); + }); + + describe("addBlock", () => { + it("should add a valid block", () => { + expect(() => fetcher.addBlock(sampleBlockInfo)).not.toThrow(); + }); + + it("should throw an error for invalid block hash", () => { + const invalidBlockInfo = { ...sampleBlockInfo, hash: "invalid_hash" }; + expect(() => fetcher.addBlock(invalidBlockInfo)).toThrowError( + "Invalid block hash: must be a 64-character hexadecimal string" + ); + }); + }); + + describe("fetchBlockInfo", () => { + it("should fetch block info after adding it", async () => { + fetcher.addBlock(sampleBlockInfo); + const blockInfo = await fetcher.fetchBlockInfo(sampleBlockInfo.hash); + expect(blockInfo).toEqual(sampleBlockInfo); + }); + + it("should throw an error if block info is not found", async () => { + await expect(fetcher.fetchBlockInfo(sampleBlockInfo.hash)).rejects.toThrowError( + `Block not found: ${sampleBlockInfo.hash}` + ); + }); + }); + + describe("addProtocolParameters", () => { + it("should add valid protocol parameters", () => { + expect(() => fetcher.addProtocolParameters(sampleProtocolParameters)).not.toThrow(); + }); + + it("should throw an error for invalid epoch", () => { + const invalidParameters = { ...sampleProtocolParameters, epoch: -1 }; + expect(() => fetcher.addProtocolParameters(invalidParameters)).toThrowError( + "Invalid epoch: must be a non-negative integer" + ); + }); + }); + + describe("fetchProtocolParameters", () => { + it("should fetch protocol parameters after adding them", async () => { + fetcher.addProtocolParameters(sampleProtocolParameters); + const parameters = await fetcher.fetchProtocolParameters(validEpoch); + expect(parameters).toEqual(sampleProtocolParameters); + }); + + it("should throw an error if protocol parameters are not found", async () => { + await expect(fetcher.fetchProtocolParameters(validEpoch)).rejects.toThrowError( + `Protocol parameters not found for epoch: ${validEpoch}` + ); + }); + }); + + describe("addTransaction", () => { + it("should add a valid transaction", () => { + expect(() => fetcher.addTransaction(sampleTransactionInfo)).not.toThrow(); + }); + + it("should throw an error for invalid transaction hash", () => { + const invalidTransactionInfo = { ...sampleTransactionInfo, hash: "invalid_hash" }; + expect(() => fetcher.addTransaction(invalidTransactionInfo)).toThrowError( + "Invalid transaction hash: must be a 64-character hexadecimal string" + ); + }); + }); + + describe("fetchTxInfo", () => { + it("should fetch transaction info after adding it", async () => { + fetcher.addTransaction(sampleTransactionInfo); + const txInfo = await fetcher.fetchTxInfo(sampleTransactionInfo.hash); + expect(txInfo).toEqual(sampleTransactionInfo); + }); + + it("should throw an error if transaction info is not found", async () => { + await expect(fetcher.fetchTxInfo(sampleTransactionInfo.hash)).rejects.toThrowError( + `Transaction not found: ${sampleTransactionInfo.hash}` + ); + }); + }); + + describe("addAssetAddresses", () => { + it("should add valid asset addresses", () => { + expect(() => fetcher.addAssetAddresses(validAsset, [sampleAssetAddress])).not.toThrow(); + }); + + it("should throw an error for invalid addresses", () => { + const invalidAssetAddress = { ...sampleAssetAddress, address: "invalid_address" }; + expect(() => fetcher.addAssetAddresses(validAsset, [invalidAssetAddress])).toThrowError( + "Invalid 'address' field at index 0, should be bech32 string" + ); + }); + }); + + describe("fetchAssetAddresses", () => { + it("should fetch asset addresses after adding them", async () => { + fetcher.addAssetAddresses(validAsset, [sampleAssetAddress]); + const addresses = await fetcher.fetchAssetAddresses(validAsset); + expect(addresses).toEqual([sampleAssetAddress]); + }); + + it("should throw an error if asset addresses are not found", async () => { + await expect(fetcher.fetchAssetAddresses(validAsset)).rejects.toThrowError( + `Asset addresses not found: ${validAsset}` + ); + }); + }); + + describe("addCollectionAssets", () => { + it("should add valid collection assets", () => { + expect(() => fetcher.addCollectionAssets([sampleAsset])).not.toThrow(); + }); + + it("should throw an error for invalid asset units", () => { + const invalidAsset = { ...sampleAsset, unit: "short_unit" }; + expect(() => fetcher.addCollectionAssets([invalidAsset])).toThrowError( + "Invalid unit for asset at index 0: must be a string longer than 56 characters" + ); + }); + }); + + describe("fetchCollectionAssets", () => { + it("should fetch collection assets after adding them", async () => { + fetcher.addCollectionAssets([sampleAsset]); + const { assets, next } = await fetcher.fetchCollectionAssets(validPolicyId); + expect(assets).toEqual([sampleAsset]); + expect(next).toBeUndefined(); + }); + + it("should throw an error if collection is not found", async () => { + await expect(fetcher.fetchCollectionAssets(validPolicyId)).rejects.toThrowError( + `Collection not found: ${validPolicyId}` + ); + }); + }); + + describe("toJSON and fromJSON", () => { + it("should serialize and deserialize correctly", () => { + fetcher.addAccount(validBech32Address, sampleAccountInfo); + fetcher.addUTxOs([sampleUTxO]); + fetcher.addAssetMetadata(validAsset, sampleAssetMetadata); + fetcher.addBlock(sampleBlockInfo); + fetcher.addProtocolParameters(sampleProtocolParameters); + fetcher.addTransaction(sampleTransactionInfo); + fetcher.addAssetAddresses(validAsset, [sampleAssetAddress]); + fetcher.addCollectionAssets([sampleAsset]); + + const json = fetcher.toJSON(); + const newFetcher = OfflineFetcher.fromJSON(json); + + // Test fetch methods + expect(newFetcher.fetchAccountInfo(validBech32Address)).resolves.toEqual(sampleAccountInfo); + expect(newFetcher.fetchAddressUTxOs(validBech32Address)).resolves.toEqual([sampleUTxO]); + expect(newFetcher.fetchAssetMetadata(validAsset)).resolves.toEqual(sampleAssetMetadata); + expect(newFetcher.fetchBlockInfo(sampleBlockInfo.hash)).resolves.toEqual(sampleBlockInfo); + expect(newFetcher.fetchProtocolParameters(validEpoch)).resolves.toEqual(sampleProtocolParameters); + expect(newFetcher.fetchTxInfo(sampleTransactionInfo.hash)).resolves.toEqual(sampleTransactionInfo); + expect(newFetcher.fetchAssetAddresses(validAsset)).resolves.toEqual([sampleAssetAddress]); + expect(newFetcher.fetchCollectionAssets(validPolicyId)).resolves.toEqual({ + assets: [sampleAsset], + next: undefined, + }); + }); + }); + + describe("Error handling in fetch methods", () => { + it("fetchUTxOs should throw error if no UTxOs are found for txHash", async () => { + await expect(fetcher.fetchUTxOs(validTxHash)).rejects.toThrowError( + `No UTxOs found for transaction hash: ${validTxHash}` + ); + }); + + it("fetchHandleAddress should throw error if handle is invalid", async () => { + await expect(fetcher.fetchHandleAddress("$invalidHandle")).rejects.toThrow(); + }); + }); + + describe("Pagination Tests", () => { + const totalAssets = 45; // Total number of assets to test pagination + const pageSize = 20; // Default page size in the paginate method + + let assets: Asset[] = []; + + beforeEach(() => { + // Generate assets for pagination tests + assets = []; + for (let i = 0; i < totalAssets; i++) { + const assetUnit = validPolicyId + i.toString().padStart(16, "0"); + assets.push({ + unit: assetUnit, + quantity: "1", + }); + } + fetcher.addCollectionAssets(assets); + }); + + it("should return the first page of assets", async () => { + const { assets: fetchedAssets, next } = await fetcher.fetchCollectionAssets( + validPolicyId + ); + expect(fetchedAssets.length).toBe(pageSize); + expect(fetchedAssets).toEqual(assets.slice(0, pageSize)); + expect(next).toBe(pageSize); // Next cursor should be the index of the next item + }); + + it("should return the second page of assets using the next cursor", async () => { + // Fetch the first page to get the 'next' cursor + const firstPage = await fetcher.fetchCollectionAssets(validPolicyId); + const nextCursor: number| string | undefined = firstPage.next; + + // Fetch the second page using the next cursor + const secondPage = await fetcher.fetchCollectionAssets( + validPolicyId, + nextCursor + ); + expect(secondPage.assets.length).toBe(pageSize); + expect(secondPage.assets).toEqual( + assets.slice(pageSize, pageSize * 2) + ); + expect(secondPage.next).toBe(pageSize * 2); + }); + + it("should return the last page of assets correctly", async () => { + // Calculate the cursor for the last page + const lastPageStartIndex = Math.floor(totalAssets / pageSize) * pageSize; + + const { assets: fetchedAssets, next } = await fetcher.fetchCollectionAssets( + validPolicyId, + lastPageStartIndex + ); + + const expectedAssets = assets.slice(lastPageStartIndex); + + expect(fetchedAssets.length).toBe(expectedAssets.length); + expect(fetchedAssets).toEqual(expectedAssets); + expect(next).toBeUndefined(); + }); + + it("should return an empty array if cursor is beyond total assets", async () => { + const beyondLastIndex = totalAssets + 10; + const { assets: fetchedAssets, next } = await fetcher.fetchCollectionAssets( + validPolicyId, + beyondLastIndex + ); + expect(fetchedAssets.length).toBe(0); + expect(fetchedAssets).toEqual([]); + expect(next).toBeUndefined(); + }); + + it("should handle invalid cursor gracefully by treating it as zero", async () => { + const invalidCursor = "invalid_cursor"; + await expect(fetcher.fetchCollectionAssets( + validPolicyId, + invalidCursor + )).rejects.toThrow(); + }); + + it("should handle custom page sizes", async () => { + // Modify the fetcher to use a custom page size + const customPageSize = 15; + + // Monkey-patch the paginate method for this test + const originalPaginate = (fetcher as any).paginate; + (fetcher as any).paginate = function ( + items: any[], + cursor: number | string | undefined + ) { + return originalPaginate.call(this, items, cursor, customPageSize); + }; + + const { assets: fetchedAssets, next } = await fetcher.fetchCollectionAssets( + validPolicyId + ); + + expect(fetchedAssets.length).toBe(customPageSize); + expect(fetchedAssets).toEqual(assets.slice(0, customPageSize)); + expect(next).toBe(customPageSize); + }); + }); + + describe("Validation Tests", () => { + describe("addAccount", () => { + it("should throw an error if 'balance' is not a string of digits", () => { + const invalidAccountInfo = { ...sampleAccountInfo, balance: "invalid_balance" }; + expect(() => fetcher.addAccount(validBech32Address, invalidAccountInfo)).toThrow( + "Invalid 'balance': must be a string of digits" + ); + }); + + it("should throw an error if 'rewards' is not a string of digits", () => { + const invalidAccountInfo = { ...sampleAccountInfo, rewards: "-100" }; + expect(() => fetcher.addAccount(validBech32Address, invalidAccountInfo)).toThrow( + "Invalid 'rewards': must be a string of digits" + ); + }); + + it("should throw an error if 'withdrawals' is not a string of digits", () => { + const invalidAccountInfo = { ...sampleAccountInfo, withdrawals: "abc" }; + expect(() => fetcher.addAccount(validBech32Address, invalidAccountInfo)).toThrow( + "Invalid 'withdrawals': must be a string of digits" + ); + }); + + it("should throw an error if 'poolId' is invalid", () => { + const invalidAccountInfo = { ...sampleAccountInfo, poolId: "invalid_poolId" }; + expect(() => fetcher.addAccount(validBech32Address, invalidAccountInfo)).toThrow( + "Invalid 'poolId': must be a valid Bech32 pool address" + ); + }); + }); + + describe("addUTxOs", () => { + it("should throw an error if 'outputIndex' is negative", () => { + const invalidUTxO = { ...sampleUTxO, input: { ...sampleUTxO.input, outputIndex: -1 } }; + expect(() => fetcher.addUTxOs([invalidUTxO])).toThrow( + "Invalid outputIndex for UTxO at index 0: must be a non-negative integer" + ); + }); + + it("should throw an error if 'output.amount' is empty", () => { + const invalidUTxO = { ...sampleUTxO, output: { ...sampleUTxO.output, amount: [] } }; + expect(() => fetcher.addUTxOs([invalidUTxO])).toThrow( + "Invalid amount for UTxO at index 0: must be a non-empty array of assets" + ); + }); + + it("should throw an error if 'unit' in amount is invalid", () => { + const invalidUTxO = { + ...sampleUTxO, + output: { + ...sampleUTxO.output, + amount: [{ unit: "invalid_unit", quantity: "1000" }], + }, + }; + expect(() => fetcher.addUTxOs([invalidUTxO])).toThrow( + "Invalid unit for asset at index 0 in UTxO at index 0" + ); + }); + + it("should throw an error if 'quantity' in amount is not a string of digits", () => { + const invalidUTxO = { + ...sampleUTxO, + output: { + ...sampleUTxO.output, + amount: [{ unit: "lovelace", quantity: "-1000" }], + }, + }; + expect(() => fetcher.addUTxOs([invalidUTxO])).toThrow( + "Invalid quantity for asset at index 0 in UTxO at index 0: must be a string of digits" + ); + }); + + it("should throw an error if 'dataHash' is invalid", () => { + const invalidUTxO = { + ...sampleUTxO, + output: { ...sampleUTxO.output, dataHash: "invalid_dataHash" }, + }; + expect(() => fetcher.addUTxOs([invalidUTxO])).toThrow( + "Invalid dataHash for UTxO at index 0: must be a 64-character hexadecimal string or undefined" + ); + }); + + it("should throw an error if 'plutusData' is invalid", () => { + const invalidUTxO = { + ...sampleUTxO, + output: { ...sampleUTxO.output, plutusData: "invalid_plutusData" }, + }; + expect(() => fetcher.addUTxOs([invalidUTxO])).toThrow( + "Invalid plutusData for UTxO at index 0: must be a hexadecimal string or undefined" + ); + }); + + it("should throw an error if 'scriptRef' is invalid", () => { + const invalidUTxO = { + ...sampleUTxO, + output: { ...sampleUTxO.output, scriptRef: "invalid_scriptRef" }, + }; + expect(() => fetcher.addUTxOs([invalidUTxO])).toThrow( + "Invalid scriptRef for UTxO at index 0: must be a hexadecimal string or undefined" + ); + }); + + it("should throw an error if 'scriptHash' is invalid", () => { + const invalidUTxO = { + ...sampleUTxO, + output: { ...sampleUTxO.output, scriptHash: "invalid_scriptHash" }, + }; + expect(() => fetcher.addUTxOs([invalidUTxO])).toThrow( + "Invalid scriptHash for UTxO at index 0: must be a 56-character hexadecimal string or undefined" + ); + }); + }); + + describe("addAssetMetadata", () => { + it("should throw an error if 'metadata' is not an object", () => { + expect(() => fetcher.addAssetMetadata(validAsset, null as any)).toThrow( + "Invalid metadata object" + ); + }); + }); + + describe("addTransaction", () => { + it("should throw an error if 'index' is negative", () => { + const invalidTransactionInfo = { ...sampleTransactionInfo, index: -1 }; + expect(() => fetcher.addTransaction(invalidTransactionInfo)).toThrow( + "Invalid 'index': must be a non-negative integer" + ); + }); + + it("should throw an error if 'fees' is not a string of digits", () => { + const invalidTransactionInfo = { ...sampleTransactionInfo, fees: "-5000" }; + expect(() => fetcher.addTransaction(invalidTransactionInfo)).toThrow( + "Invalid 'fees': must be a string of digits" + ); + }); + + it("should throw an error if 'size' is not a positive integer", () => { + const invalidTransactionInfo = { ...sampleTransactionInfo, size: 0 }; + expect(() => fetcher.addTransaction(invalidTransactionInfo)).toThrow( + "Invalid 'size': must be a positive integer" + ); + }); + + it("should throw an error if 'deposit' is not an integer string", () => { + const invalidTransactionInfo = { ...sampleTransactionInfo, deposit: "invalid_deposit" }; + expect(() => fetcher.addTransaction(invalidTransactionInfo)).toThrow( + "Invalid 'deposit': must be a string representing an integer (positive or negative)" + ); + }); + + it("should throw an error if 'invalidBefore' is invalid", () => { + const invalidTransactionInfo = { ...sampleTransactionInfo, invalidBefore: "abc" }; + expect(() => fetcher.addTransaction(invalidTransactionInfo)).toThrow( + "Invalid 'invalidBefore': must be a string of digits or empty string" + ); + }); + + it("should throw an error if 'invalidAfter' is invalid", () => { + const invalidTransactionInfo = { ...sampleTransactionInfo, invalidAfter: "-100" }; + expect(() => fetcher.addTransaction(invalidTransactionInfo)).toThrow( + "Invalid 'invalidAfter': must be a string of digits or empty string" + ); + }); + }); + + describe("addBlock", () => { + it("should throw an error if 'time' is negative", () => { + const invalidBlockInfo = { ...sampleBlockInfo, time: -1 }; + expect(() => fetcher.addBlock(invalidBlockInfo)).toThrow( + "Invalid 'time': must be a non-negative integer" + ); + }); + + it("should throw an error if 'epoch' is negative", () => { + const invalidBlockInfo = { ...sampleBlockInfo, epoch: -1 }; + expect(() => fetcher.addBlock(invalidBlockInfo)).toThrow( + "Invalid 'epoch': must be a non-negative integer" + ); + }); + + it("should throw an error if 'size' is not a positive integer", () => { + const invalidBlockInfo = { ...sampleBlockInfo, size: 0 }; + expect(() => fetcher.addBlock(invalidBlockInfo)).toThrow( + "Invalid 'size': must be a positive integer" + ); + }); + + it("should throw an error if 'txCount' is negative", () => { + const invalidBlockInfo = { ...sampleBlockInfo, txCount: -1 }; + expect(() => fetcher.addBlock(invalidBlockInfo)).toThrow( + "Invalid 'txCount': must be a non-negative integer" + ); + }); + + it("should throw an error if 'slotLeader' is invalid", () => { + const invalidBlockInfo = { ...sampleBlockInfo, slotLeader: "invalid_slotLeader" }; + expect(() => fetcher.addBlock(invalidBlockInfo)).toThrow( + "Invalid 'slotLeader': must be a bech32 string with pool prefix" + ); + }); + + it("should throw an error if 'VRFKey' is invalid", () => { + const invalidBlockInfo = { ...sampleBlockInfo, VRFKey: "invalid_VRFKey" }; + expect(() => fetcher.addBlock(invalidBlockInfo)).toThrow( + "Invalid 'VRFKey': must be a bech32 string with vrf_vk1 prefix" + ); + }); + }); + + describe("addProtocolParameters", () => { + it("should throw an error if 'minFeeA' is negative", () => { + const invalidParameters = { ...sampleProtocolParameters, minFeeA: -1 }; + expect(() => fetcher.addProtocolParameters(invalidParameters)).toThrow( + "Invalid 'minFeeA': must be a non-negative integer" + ); + }); + + it("should throw an error if 'priceMem' is negative", () => { + const invalidParameters = { ...sampleProtocolParameters, priceMem: -0.1 }; + expect(() => fetcher.addProtocolParameters(invalidParameters)).toThrow( + "Invalid 'priceMem': must be non-negative" + ); + }); + + it("should throw an error if 'decentralisation' is out of bounds", () => { + const invalidParametersHigh = { ...sampleProtocolParameters, decentralisation: 1.1 }; + const invalidParametersLow = { ...sampleProtocolParameters, decentralisation: -0.1 }; + expect(() => fetcher.addProtocolParameters(invalidParametersHigh)).toThrow( + "Invalid 'decentralisation': must be between 0 and 1" + ); + expect(() => fetcher.addProtocolParameters(invalidParametersLow)).toThrow( + "Invalid 'decentralisation': must be between 0 and 1" + ); + }); + + it("should throw an error if 'maxTxExMem' is not a string of digits", () => { + const invalidParameters = { ...sampleProtocolParameters, maxTxExMem: "invalid_value" }; + expect(() => fetcher.addProtocolParameters(invalidParameters)).toThrow( + "Invalid 'maxTxExMem': must be a string of digits" + ); + }); + + it("should throw an error if 'maxValSize' is negative", () => { + const invalidParameters = { ...sampleProtocolParameters, maxValSize: -1 }; + expect(() => fetcher.addProtocolParameters(invalidParameters)).toThrow( + "Invalid 'maxValSize': must be a non-negative integer" + ); + }); + }); + + describe("addAssetAddresses", () => { + it("should throw an error if 'quantity' is not a string of digits", () => { + const invalidAssetAddress = { ...sampleAssetAddress, quantity: "-1000" }; + expect(() => fetcher.addAssetAddresses(validAsset, [invalidAssetAddress])).toThrow( + "Invalid 'quantity' field at index 0, should be a string of digits" + ); + }); + + it("should throw an error if 'addresses' is empty", () => { + expect(() => fetcher.addAssetAddresses(validAsset, [])).toThrow( + "Invalid addresses: must be a non-empty array" + ); + }); + }); + + describe("addCollectionAssets", () => { + it("should throw an error if 'assets' is empty", () => { + expect(() => fetcher.addCollectionAssets([])).toThrow( + "Invalid assets: must be a non-empty array" + ); + }); + + it("should throw an error if 'quantity' is not a string of digits", () => { + const invalidAsset = { ...sampleAsset, quantity: "-1000" }; + expect(() => fetcher.addCollectionAssets([invalidAsset])).toThrow( + "Invalid quantity for asset at index 0: must be a string of digits" + ); + }); + }); + }); +});