From f1f5a9f7da2a04374379f4e80a734dd9e85274ba Mon Sep 17 00:00:00 2001 From: Dudi Edri Date: Thu, 2 Jul 2026 22:26:08 +0300 Subject: [PATCH] feat: add NexusProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blockchain data provider for the Nexus API (nexus.gerowallet.io): implements IFetcher, IListener, ISubmitter and IEvaluator — address/asset/ block/tx queries, account info, protocol params & cost models, tx submission, tx evaluation, and reference-script/script-by-hash resolution. Includes src/types/nexus.ts and hermetic fetcher/evaluator tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 2 + src/index.ts | 1 + src/nexus.ts | 836 +++++++++++++++++++++++++++++++++++ src/types/index.ts | 1 + src/types/nexus.ts | 113 +++++ test/nexus/evaluator.test.ts | 102 +++++ test/nexus/fetcher.test.ts | 126 ++++++ 7 files changed, 1181 insertions(+) create mode 100644 src/nexus.ts create mode 100644 src/types/nexus.ts create mode 100644 test/nexus/evaluator.test.ts create mode 100644 test/nexus/fetcher.test.ts diff --git a/.env.example b/.env.example index 3e4664e..da9bead 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,5 @@ BLOCKFROST_API_KEY_PREPROD=preprodxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx MAESTRO_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx KOIOS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx OGMIOS_API_URL=https://your-ogmios-endpoint.demeter.run +NEXUS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +NEXUS_API_URL=https://nexus.gerowallet.io/api diff --git a/src/index.ts b/src/index.ts index 3b354d9..0e36f37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from "./begin"; export * from "./blockfrost"; export * from "./koios"; export * from "./maestro"; +export * from "./nexus"; export * from "./ogmios"; export * from "./utxo-rpc"; export * from "./yaci"; diff --git a/src/nexus.ts b/src/nexus.ts new file mode 100644 index 0000000..9408ba7 --- /dev/null +++ b/src/nexus.ts @@ -0,0 +1,836 @@ +import axios, { AxiosInstance } from "axios"; + +import { + AccountInfo, + Action, + Asset, + AssetMetadata, + BlockInfo, + castProtocol, + DEFAULT_FETCHER_OPTIONS, + fromUTF8, + GovernanceProposalInfo, + IEvaluator, + IFetcher, + IFetcherOptions, + IListener, + ISubmitter, + PlutusScript, + Protocol, + RedeemerTagType, + SUPPORTED_HANDLES, + TransactionInfo, + UTxO, +} from "@meshsdk/common"; +import { + deserializeNativeScript, + fromNativeScript, + normalizePlutusScript, + resolveRewardAddress, + toScriptRef, +} from "@meshsdk/core-cst"; + +import { utxosToAssets } from "./common/utxos-to-assets"; +import { + NexusAddressUTxO, + NexusReferenceScript, + NexusRedeemerEval, + NexusScript, + NexusUTxO, +} from "./types"; +import { getAdditionalUtxos, parseAssetUnit, parseHttpError } from "./utils"; + +export type NexusSupportedNetworks = "mainnet" | "preprod" | "preview"; + +const DEFAULT_NEXUS_URL = "https://nexus.gerowallet.io/api"; +const DEFAULT_PAGE_SIZE = 100; + +/** + * Nexus is Gero's Cardano data API (https://nexus.gerowallet.io). It exposes + * a Blockfrost/Koios-style REST surface for querying chain data and submitting + * transactions. + * + * Usage: + * ``` + * import { NexusProvider } from "@meshsdk/provider"; + * + * // hosted instance, API key is network-scoped + * const provider = new NexusProvider(''); + * + * // self-hosted instance (include the `/api` path segment) + * const provider = new NexusProvider('https://my-nexus.example.com/api'); + * ``` + */ +export class NexusProvider + implements IFetcher, IListener, ISubmitter, IEvaluator +{ + private readonly _axiosInstance: AxiosInstance; + private readonly _network: NexusSupportedNetworks; + + /** + * If you are using a privately hosted Nexus instance, pass its base URL + * (including the `/api` path). Optionally pass an API key and the network the + * instance serves. + * @param baseUrl The base URL of the instance, e.g. `https://host/api`. + * @param apiKey Optional API key sent as the `X-Api-Key` header. + * @param network Optional network the instance serves. Default is `mainnet`. + */ + constructor( + baseUrl: string, + apiKey?: string, + network?: NexusSupportedNetworks, + ); + + /** + * If you are using the hosted Nexus instance, pass your API key. The key is + * network-scoped, so the network is derived from the key server-side. + * @param apiKey Your Nexus API key. + * @param network Optional network hint used for handle resolution. Default `mainnet`. + */ + constructor(apiKey: string, network?: NexusSupportedNetworks); + + constructor(...args: unknown[]) { + const first = args[0] as string; + const isUrl = + typeof first === "string" && + (first.startsWith("http") || first.startsWith("/")); + + if (isUrl) { + const apiKey = typeof args[1] === "string" ? args[1] : undefined; + this._network = (args[2] as NexusSupportedNetworks) ?? "mainnet"; + this._axiosInstance = axios.create({ + baseURL: first, + headers: apiKey ? { "X-Api-Key": apiKey } : undefined, + }); + } else { + const apiKey = first; + this._network = (args[1] as NexusSupportedNetworks) ?? "mainnet"; + this._axiosInstance = axios.create({ + baseURL: DEFAULT_NEXUS_URL, + headers: { "X-Api-Key": apiKey }, + }); + } + } + + /** + * Evaluates the resources required to execute the transaction. + * Requires the Nexus instance to have an evaluation backend configured + * (Ogmios); otherwise the endpoint responds 503. + * @param cbor - The transaction in CBOR hex to evaluate + * @param additionalUtxos - Additional UTxOs referenced by the transaction but not yet on-chain + * @param additionalTxs - Additional (chained) transactions whose outputs the tx spends + */ + async evaluateTx( + cbor: string, + additionalUtxos?: UTxO[], + additionalTxs?: string[], + ): Promise[]> { + const additionalUtxoSet = getAdditionalUtxos( + "ogmios", + additionalUtxos, + additionalTxs, + ); + + try { + const headers = { "Content-Type": "application/json" }; + const { status, data } = await this._axiosInstance.post( + "transactions/evaluate", + { cbor, additionalUtxoSet }, + { headers }, + ); + + if ((status === 200 || status === 202) && Array.isArray(data)) { + const tagMap: { [key: string]: RedeemerTagType } = { + spend: "SPEND", + mint: "MINT", + cert: "CERT", + certificate: "CERT", + publish: "CERT", + reward: "REWARD", + withdraw: "REWARD", + withdrawal: "REWARD", + }; + + return data.map( + (redeemer: NexusRedeemerEval) => + >{ + tag: tagMap[redeemer.redeemerTag.toLowerCase()]!, + index: Number(redeemer.index), + budget: { + mem: Number(redeemer.exUnits.mem), + steps: Number(redeemer.exUnits.steps), + }, + }, + ); + } + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + /** + * Obtain information about a specific stake account. + * @param address - Wallet address (or stake address) to fetch account information + */ + async fetchAccountInfo(address: string): Promise { + const rewardAddress = address.startsWith("addr") + ? resolveRewardAddress(address) + : address; + + try { + const { data, status } = await this._axiosInstance.get( + `account/${rewardAddress}/info`, + ); + + if (status === 200 || status === 202) + return { + poolId: data.poolId, + active: data.active ?? data.activeEpoch != null, + balance: data.controlledAmount, + rewards: data.withdrawableAmount, + withdrawals: data.withdrawalsSum, + }; + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + /** + * Fetches the assets for a given address. + * @param address - The address to fetch assets for + * @returns A map of asset unit to quantity + */ + async fetchAddressAssets( + address: string, + ): Promise<{ [key: string]: string }> { + const utxos = await this.fetchAddressUTxOs(address); + return utxosToAssets(utxos); + } + + /** + * Transactions for an address. + * @param address + * @param option - Fetcher options (pagination) + * @returns - partial TransactionInfo + */ + async fetchAddressTxs( + address: string, + option: IFetcherOptions = DEFAULT_FETCHER_OPTIONS, + ): Promise { + const txs: TransactionInfo[] = []; + try { + const fetcherOptions = { ...DEFAULT_FETCHER_OPTIONS, ...option }; + + for (let page = 1; page <= fetcherOptions.maxPage!; page++) { + const { data, status } = await this._axiosInstance.get( + `addresses/transactions/${address}?page=${page}&pageSize=${DEFAULT_PAGE_SIZE}`, + ); + if (status !== 200 && status !== 202) throw parseHttpError(data); + if (!data || data.length === 0) break; + + for (const tx of data) { + txs.push({ + hash: tx.txHash, + index: tx.txIndex ?? 0, + block: "", + slot: tx.slot?.toString() ?? "", + fees: "", + size: 0, + deposit: "", + invalidBefore: "", + invalidAfter: "", + }); + } + } + return txs; + } catch (error) { + throw parseHttpError(error); + } + } + + /** + * UTXOs of the address. + * @param address - The address to fetch UTXOs for + * @param asset - UTXOs of a given asset (policyId + assetName hex) + * @returns - Array of UTxOs + */ + async fetchAddressUTxOs(address: string, asset?: string): Promise { + const filter = asset !== undefined ? `/${asset}` : ""; + const url = `addresses/${address}/utxos${filter}`; + + const paginateUTxOs = async ( + page = 1, + utxos: UTxO[] = [], + ): Promise => { + const { data, status } = await this._axiosInstance.get( + `${url}?page=${page}&pageSize=${DEFAULT_PAGE_SIZE}`, + ); + + if (status === 200 || status === 202) + return data.length > 0 + ? paginateUTxOs(page + 1, [ + ...utxos, + ...data.map((utxo: NexusAddressUTxO) => this.toUTxO(utxo)), + ]) + : utxos; + + throw parseHttpError(data); + }; + + try { + return await paginateUTxOs(); + } catch (error) { + return []; + } + } + + /** + * Fetches the asset addresses (holders) for a given asset. + * @param asset - The asset to fetch addresses for + */ + async fetchAssetAddresses( + asset: string, + ): Promise<{ address: string; quantity: string }[]> { + const { policyId, assetName } = parseAssetUnit(asset); + const unit = `${policyId}${assetName}`; + + const paginateAddresses = async ( + page = 1, + addresses: { address: string; quantity: string }[] = [], + ): Promise<{ address: string; quantity: string }[]> => { + const { data, status } = await this._axiosInstance.get( + `assets/${unit}/holders?page=${page}&pageSize=${DEFAULT_PAGE_SIZE}`, + ); + + if (status === 200 || status === 202) + return data.length > 0 + ? paginateAddresses(page + 1, [ + ...addresses, + ...data.map((holder: { address: string; quantity: string }) => ({ + address: holder.address, + quantity: holder.quantity, + })), + ]) + : addresses; + + throw parseHttpError(data); + }; + + try { + return await paginateAddresses(); + } catch (error) { + return []; + } + } + + /** + * Fetches the metadata for a given asset. + * @param asset - The asset to fetch metadata for + * @returns The metadata for the asset + */ + async fetchAssetMetadata(asset: string): Promise { + try { + const { policyId, assetName } = parseAssetUnit(asset); + const { data, status } = await this._axiosInstance.get( + `assets/detailedInfo?assetPolicy=${policyId}&assetName=${assetName}`, + ); + + if (status === 200 || status === 202) { + const onchainMetadata = + typeof data.onchainMetadata === "string" && data.onchainMetadata + ? JSON.parse(data.onchainMetadata) + : (data.onchainMetadata ?? {}); + + return { + ...onchainMetadata, + fingerprint: data.fingerprint, + totalSupply: data.quantity, + mintingTxHash: data.initialMintTxHash, + mintCount: data.mintOrBurnCount, + }; + } + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + /** + * Fetches the latest block. + * @returns The latest block information + */ + async fetchLatestBlock(): Promise { + try { + const { data, status } = await this._axiosInstance.get(`blocks/latest`); + + if (status === 200 || status === 202) return this.toBlockInfo(data); + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + async fetchBlockInfo(hash: string): Promise { + try { + const { data, status } = await this._axiosInstance.get(`blocks/${hash}`); + + if (status === 200 || status === 202) return this.toBlockInfo(data); + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + async fetchCollectionAssets( + policyId: string, + cursor = 1, + ): Promise<{ assets: Asset[]; next: string | number | null }> { + try { + const { data, status } = await this._axiosInstance.get( + `policy/${policyId}/assets?page=${cursor}&pageSize=${DEFAULT_PAGE_SIZE}`, + ); + + if (status === 200 || status === 202) + return { + assets: data.map((asset: { unit: string; quantity: string }) => ({ + unit: asset.unit, + quantity: asset.quantity, + })), + next: data.length === DEFAULT_PAGE_SIZE ? Number(cursor) + 1 : null, + }; + + throw parseHttpError(data); + } catch (error) { + return { assets: [], next: null }; + } + } + + async fetchHandle(handle: string): Promise { + if (this._network !== "mainnet") { + throw new Error( + "Does not support fetching addresses by handle on non-mainnet networks.", + ); + } + try { + const assetName = fromUTF8(handle.replace("$", "")); + return await this.fetchAssetMetadata( + `${SUPPORTED_HANDLES[1]}000de140${assetName}`, + ); + } catch (error) { + throw parseHttpError(error); + } + } + + async fetchHandleAddress(handle: string): Promise { + if (this._network !== "mainnet") { + throw new Error( + "Does not support fetching addresses by handle on non-mainnet networks.", + ); + } + try { + const assetName = fromUTF8(handle.replace("$", "")); + const addresses = await this.fetchAssetAddresses( + `${SUPPORTED_HANDLES[1]}${assetName}`, + ); + return addresses[0]!.address; + } catch (error) { + throw parseHttpError(error); + } + } + + async fetchCostModels(epoch?: number): Promise { + try { + const url = + epoch !== undefined && !isNaN(epoch) + ? `epoch/params?epoch_no=${epoch}` + : `epoch/latest/parameters`; + const { data, status } = await this._axiosInstance.get(url); + + if (status === 200 || status === 202) { + const costModels = data.costModels ?? {}; + return [costModels.PlutusV1, costModels.PlutusV2, costModels.PlutusV3] + .filter((model) => model != null) + .map((model) => + Object.values(model as Record).map(Number), + ); + } + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + async fetchProtocolParameters(epoch = Number.NaN): Promise { + try { + const paramsUrl = isNaN(epoch) + ? `epoch/latest/parameters` + : `epoch/params?epoch_no=${epoch}`; + const { data, status } = await this._axiosInstance.get(paramsUrl); + + if (status === 200 || status === 202) { + let epochNo = epoch; + if (isNaN(epochNo)) { + try { + const latest = await this._axiosInstance.get(`epoch/latest`); + epochNo = latest.data?.epoch; + } catch (error) { + // epoch number is best-effort; castProtocol falls back to default + } + } + + return castProtocol({ + epoch: epochNo, + minFeeA: data.minFeeA, + minFeeB: data.minFeeB, + maxBlockSize: data.maxBlockSize, + maxTxSize: data.maxTxSize, + maxBlockHeaderSize: data.maxBlockHeaderSize, + keyDeposit: data.keyDeposit, + poolDeposit: data.poolDeposit, + decentralisation: data.decentralisationParam, + minPoolCost: data.minPoolCost, + priceMem: data.priceMem, + priceStep: data.priceStep, + maxTxExMem: data.maxTxExMem, + maxTxExSteps: data.maxTxExSteps, + maxBlockExMem: data.maxBlockExMem, + maxBlockExSteps: data.maxBlockExSteps, + maxValSize: data.maxValSize, + collateralPercent: data.collateralPercent, + maxCollateralInputs: data.maxCollateralInputs, + coinsPerUtxoSize: data.coinsPerUtxoSize, + minFeeRefScriptCostPerByte: data.minFeeRefScriptCostPerByte, + }); + } + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + async fetchTxInfo(hash: string): Promise { + try { + const { data, status } = await this._axiosInstance.get( + `transactions/${hash}`, + ); + + if (status === 200 || status === 202) + return { + block: data.block_hash, + deposit: data.deposit ?? "", + fees: data.fee ?? "", + hash: data.txHash, + index: 0, + invalidAfter: data.invalid_after?.toString() ?? "", + invalidBefore: data.invalid_before?.toString() ?? "", + slot: data.absolute_slot?.toString() ?? "", + size: data.txSize ?? 0, + }; + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + async fetchUTxOs(hash: string, index?: number): Promise { + try { + const { data, status } = await this._axiosInstance.get( + `transactions/${hash}/utxos`, + ); + + if (status === 200 || status === 202) { + const outputs: UTxO[] = await Promise.all( + (data.outputs ?? []).map((utxo: NexusUTxO) => + this.txUtxoToUTxO(utxo), + ), + ); + + if (index !== undefined) { + return outputs.filter((utxo) => utxo.input.outputIndex === index); + } + + return outputs; + } + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + async fetchGovernanceProposal( + txHash: string, + certIndex: number, + ): Promise { + try { + const govActionId = `${txHash}%23${certIndex}`; + const { data, status } = await this._axiosInstance.get( + `governance/proposals/${govActionId}`, + ); + + if (status === 200 || status === 202) + return { + txHash: data.txHash, + certIndex: data.index, + governanceType: data.type, + deposit: Number(data.deposit), + returnAddress: data.returnAddress, + governanceDescription: data.anchorUrl ?? "", + ratifiedEpoch: 0, + enactedEpoch: 0, + droppedEpoch: 0, + expiredEpoch: 0, + expiration: 0, + metadata: data.rawMetadata ?? data.govAction ?? {}, + }; + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + /** + * A generic method to fetch data from a URL. + * @param url - The URL to fetch data from + * @returns - The data fetched from the URL + */ + async get(url: string): Promise { + try { + const { data, status } = await this._axiosInstance.get(url); + if (status === 200 || status === 202) { + return data; + } + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + /** + * A generic method to post data to a URL. + * @param url - The URL to post data to + * @param body - Payload + * @param headers - Specify headers, default: { "Content-Type": "application/json" } + * @returns - Data + */ + async post( + url: string, + body: any, + headers = { "Content-Type": "application/json" }, + ): Promise { + try { + const { data, status } = await this._axiosInstance.post(url, body, { + headers, + }); + + if (status === 200 || status === 202) return data; + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + /** + * Allow you to listen to a transaction confirmation. Upon confirmation, the callback will be called. + * @param txHash - The transaction hash to listen for confirmation + * @param callback - The callback function to call when the transaction is confirmed + * @param limit - The number of attempts to make before giving up + */ + onTxConfirmed(txHash: string, callback: () => void, limit = 100): void { + let attempts = 0; + + const checkTx = setInterval(() => { + if (attempts >= limit) clearInterval(checkTx); + + this.fetchTxInfo(txHash) + .then((txInfo) => { + this.fetchBlockInfo(txInfo.block) + .then((blockInfo) => { + if (blockInfo?.confirmations > 0) { + clearInterval(checkTx); + callback(); + } + }) + .catch(() => { + attempts += 1; + }); + }) + .catch(() => { + attempts += 1; + }); + }, 5_000); + } + + /** + * Submit a serialized transaction to the network. + * @param tx - The serialized transaction in hex to submit + * @returns The transaction hash of the submitted transaction + */ + async submitTx(tx: string): Promise { + try { + const headers = { "Content-Type": "text/plain" }; + const { data, status } = await this._axiosInstance.post( + `transactions/submit`, + tx, + { headers }, + ); + + if (status === 200 || status === 202) return data; + + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + private toBlockInfo(data: any): BlockInfo { + return { + confirmations: data.confirmations, + epoch: data.epoch, + epochSlot: data.epoch_slot?.toString() ?? "", + fees: data.fees ?? "", + hash: data.hash, + nextBlock: data.next_block ?? "", + operationalCertificate: data.op_cert ?? "", + output: data.output ?? "0", + previousBlock: data.previous_block ?? "", + size: data.size, + slot: data.slot?.toString() ?? "", + slotLeader: data.slot_leader ?? "", + time: data.time, + txCount: data.tx_count, + VRFKey: data.block_vrf ?? "", + }; + } + + private toUTxO = (utxo: NexusAddressUTxO): UTxO => ({ + input: { + outputIndex: utxo.txIndex, + txHash: utxo.txHash, + }, + output: { + address: utxo.address, + amount: [ + { unit: "lovelace", quantity: utxo.value }, + ...(utxo.assets ?? []).map( + (asset) => + { + unit: asset.unit, + quantity: asset.quantity, + }, + ), + ], + dataHash: utxo.datumHash ?? undefined, + plutusData: utxo.inlineDatum?.bytes ?? undefined, + scriptRef: this.resolveScriptRef(utxo.referenceScript), + scriptHash: utxo.referenceScript?.hash ?? undefined, + }, + }); + + private txUtxoToUTxO = async (utxo: NexusUTxO): Promise => ({ + input: { + outputIndex: utxo.output_index, + txHash: utxo.tx_hash, + }, + output: { + address: utxo.owner_addr, + // The transaction UTxO `amounts` already include the lovelace entry. + amount: (utxo.amounts ?? []).map( + (asset) => + { + unit: asset.unit, + quantity: asset.quantity, + }, + ), + dataHash: utxo.data_hash ?? undefined, + plutusData: utxo.inline_datum ?? undefined, + // The tx-scoped UTxO shape may carry the script CBOR directly (`script_ref`); + // otherwise resolve it from the reference script hash. + scriptRef: + utxo.script_ref ?? + (await this.resolveScriptRefByHash(utxo.reference_script_hash)), + scriptHash: utxo.reference_script_hash ?? undefined, + }, + }); + + /** + * Fetches a script by its hash. + * @param scriptHash - The hash of the script to fetch + * @returns The script (type + bytes), or undefined if not found + */ + async fetchScriptByHash( + scriptHash: string, + ): Promise { + try { + const { data, status } = await this._axiosInstance.get( + `scripts/${scriptHash}`, + ); + if (status === 200 || status === 202) return data as NexusScript; + throw parseHttpError(data); + } catch (error) { + throw parseHttpError(error); + } + } + + private resolveScriptRef = ( + referenceScript: NexusReferenceScript | undefined, + ): string | undefined => { + if (referenceScript && referenceScript.bytes) { + return this.scriptRefFromParts( + referenceScript.type, + referenceScript.bytes, + ); + } + return undefined; + }; + + private resolveScriptRefByHash = async ( + scriptHash?: string, + ): Promise => { + if (!scriptHash) return undefined; + try { + const { data, status } = await this._axiosInstance.get( + `scripts/${scriptHash}`, + ); + if (status === 200 || status === 202) { + const script = data as NexusScript; + const bytes = script.bytes ?? script.cbor; + if (bytes) return this.scriptRefFromParts(script.type, bytes); + } + } catch (error) { + // Best-effort: a script-resolution failure must not break UTxO fetching. + } + return undefined; + }; + + private scriptRefFromParts = ( + type: string, + bytes: string, + ): string | undefined => { + let script; + if (type.startsWith("plutus")) { + const normalized = normalizePlutusScript(bytes, "DoubleCBOR"); + script = { + code: normalized, + version: type.replace("plutus", ""), + }; + } else { + script = fromNativeScript(deserializeNativeScript(bytes)); + } + + if (script) return toScriptRef(script).toCbor().toString(); + return undefined; + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index d3badf1..82682ad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ export * from "./blockfrost"; export * from "./koios"; +export * from "./nexus"; export * from "./maestro"; export * from "./ogmios"; diff --git a/src/types/nexus.ts b/src/types/nexus.ts new file mode 100644 index 0000000..8750839 --- /dev/null +++ b/src/types/nexus.ts @@ -0,0 +1,113 @@ +/** + * Types describing the JSON shapes returned by the Nexus API + * (https://nexus.gerowallet.io). Only the fields consumed by + * {@link NexusProvider} are modelled here. + * + * Note: Nexus serialises most Cardano data DTOs in camelCase, but the + * transaction-scoped UTxO endpoints (`/transactions/*`) use snake_case. + * The two UTxO shapes below reflect that split. + */ + +/** Inline datum (CIP-32) as returned on address UTxOs. */ +export type NexusInlineDatum = { + bytes: string; + value?: unknown; +}; + +/** Reference script (CIP-33) as returned on address UTxOs. */ +export type NexusReferenceScript = { + hash: string; + size?: number; + /** e.g. "plutusV1" | "plutusV2" | "plutusV3" | "timelock" (native). */ + type: string; + bytes: string; + value?: unknown; +}; + +/** A single asset balance entry on an address UTxO. */ +export type NexusAssetBalance = { + /** "lovelace" for ADA, otherwise policyId + assetName (hex). */ + unit: string; + policyId?: string; + assetName?: string; + fingerprint?: string; + quantity: string; + decimals?: number; +}; + +/** UTxO shape from `GET /addresses/{address}/utxos` (camelCase). */ +export type NexusAddressUTxO = { + txHash: string; + txIndex: number; + address: string; + stakeAddress?: string; + /** Lovelace amount. */ + value: string; + datumHash?: string; + inlineDatum?: NexusInlineDatum; + referenceScript?: NexusReferenceScript; + assets?: NexusAssetBalance[]; + blockHash?: string; + blockHeight?: number; + slot?: number; +}; + +/** A single amount entry on a transaction UTxO. */ +export type NexusAmount = { + /** "lovelace" for ADA, otherwise policyId + assetName (hex). */ + unit: string; + quantity: string; +}; + +/** UTxO shape from `GET /transactions/{txHash}/utxos` (snake_case). */ +export type NexusUTxO = { + tx_hash: string; + output_index: number; + owner_addr: string; + amounts?: NexusAmount[]; + lovelace_amount?: number; + data_hash?: string; + inline_datum?: string; + reference_script_hash?: string; + script_ref?: string; +}; + +/** One asset minted under a policy id, from `GET /policy/{policyId}/assets`. */ +export type NexusPolicyAsset = { + policyId: string; + assetName: string; + unit: string; + fingerprint?: string; + quantity: string; +}; + +/** Holder entry from `GET /assets/{unit}/holders`. */ +export type NexusAssetHolder = { + address: string; + quantity: string; +}; + +/** + * A single redeemer evaluation result from `POST /transactions/evaluate`. + * `redeemerTag` follows the Ogmios purpose vocabulary + * (spend | mint | publish/cert | withdraw/reward). + */ +export type NexusRedeemerEval = { + redeemerTag: string; + index: number; + exUnits: { + mem: number | string; + steps: number | string; + }; +}; + +/** Script details from `GET /scripts/{scriptHash}`. */ +export type NexusScript = { + hash: string; + /** e.g. "plutusV1" | "plutusV2" | "plutusV3" | "timelock" (native). */ + type: string; + /** Raw script bytes in hex; some sources return it under `cbor`. */ + bytes?: string; + cbor?: string; + value?: unknown; +}; diff --git a/test/nexus/evaluator.test.ts b/test/nexus/evaluator.test.ts new file mode 100644 index 0000000..47cf07b --- /dev/null +++ b/test/nexus/evaluator.test.ts @@ -0,0 +1,102 @@ +import { NexusProvider } from "@meshsdk/provider"; + +/** + * Hermetic tests for NexusProvider evaluate / script resolution: the internal + * axios instance is stubbed so the mapping logic runs without a live server. + */ +const makeProvider = ( + get: (url: string) => Promise, + post?: (url: string, body: any, config: any) => Promise, +) => { + const provider = new NexusProvider("https://nexus.test/api"); + (provider as any)._axiosInstance = { + get: jest.fn(get), + post: post ? jest.fn(post) : jest.fn(), + }; + return provider; +}; + +describe("NexusProvider evaluator", () => { + it("maps redeemer evaluation results to Mesh actions", async () => { + const post = jest.fn(async (url: string, body: any) => { + expect(url).toBe("transactions/evaluate"); + expect(body.cbor).toBe("aabbcc"); + return { + status: 200, + data: [ + { + redeemerTag: "spend", + index: 0, + exUnits: { mem: 2000, steps: 500000 }, + }, + { + redeemerTag: "publish", + index: 1, + exUnits: { mem: 1000, steps: 250000 }, + }, + ], + }; + }); + const provider = makeProvider(async () => ({ status: 200 }), post); + + const res = await provider.evaluateTx("aabbcc"); + expect(res).toEqual([ + { tag: "SPEND", index: 0, budget: { mem: 2000, steps: 500000 } }, + { tag: "CERT", index: 1, budget: { mem: 1000, steps: 250000 } }, + ]); + }); + + it("surfaces evaluation errors (e.g. dormant 503 backend)", async () => { + const post = jest.fn(async () => { + throw { + response: { status: 503, data: "evaluation backend not configured" }, + }; + }); + const provider = makeProvider(async () => ({ status: 200 }), post); + + const res = await provider.evaluateTx("aabbcc").catch(() => "error"); + expect(res).toBe("error"); + }); + + it("resolves a native reference script by hash in tx UTxOs", async () => { + // A trivial native script (RequireAllOf []) as its serialized bytes. + const nativeScriptBytes = + "8200581c00000000000000000000000000000000000000000000000000000000"; + const get = jest.fn(async (url: string) => { + if (url.startsWith("transactions/")) { + return { + status: 200, + data: { + hash: "dd".repeat(32), + outputs: [ + { + tx_hash: "dd".repeat(32), + output_index: 0, + owner_addr: "addr_test1a", + amounts: [{ unit: "lovelace", quantity: "1000000" }], + reference_script_hash: "ee".repeat(28), + }, + ], + }, + }; + } + // scripts/{hash} + return { + status: 200, + data: { + hash: "ee".repeat(28), + type: "timelock", + bytes: nativeScriptBytes, + }, + }; + }); + const provider = makeProvider(get); + + const utxos = await provider.fetchUTxOs("dd".repeat(32)); + expect(utxos).toHaveLength(1); + expect(utxos[0]!.output.scriptHash).toBe("ee".repeat(28)); + // A script ref CBOR string should have been resolved from the hash lookup. + expect(typeof utxos[0]!.output.scriptRef).toBe("string"); + expect(utxos[0]!.output.scriptRef!.length).toBeGreaterThan(0); + }); +}); diff --git a/test/nexus/fetcher.test.ts b/test/nexus/fetcher.test.ts new file mode 100644 index 0000000..f285799 --- /dev/null +++ b/test/nexus/fetcher.test.ts @@ -0,0 +1,126 @@ +import { NexusProvider } from "@meshsdk/provider"; + +/** + * Hermetic tests for NexusProvider: the internal axios instance is stubbed so + * the response-mapping logic is exercised without a live Nexus server. + */ +const makeProvider = ( + get: (url: string) => Promise, + post?: (url: string, body: any, config: any) => Promise, +) => { + const provider = new NexusProvider("https://nexus.test/api"); + (provider as any)._axiosInstance = { + get: jest.fn(get), + post: post ? jest.fn(post) : jest.fn(), + }; + return provider; +}; + +describe("NexusProvider fetcher", () => { + it("maps address UTxOs (camelCase) including lovelace and assets", async () => { + let page = 0; + const provider = makeProvider(async () => { + page += 1; + if (page > 1) return { status: 200, data: [] }; + return { + status: 200, + data: [ + { + txHash: "aa".repeat(32), + txIndex: 1, + address: "addr_test1xyz", + value: "2000000", + datumHash: "bb".repeat(32), + inlineDatum: { bytes: "d87980" }, + assets: [{ unit: "cccc0011", quantity: "5" }], + }, + ], + }; + }); + + const utxos = await provider.fetchAddressUTxOs("addr_test1xyz"); + expect(utxos).toHaveLength(1); + expect(utxos[0]!.input).toEqual({ + txHash: "aa".repeat(32), + outputIndex: 1, + }); + expect(utxos[0]!.output.amount).toEqual([ + { unit: "lovelace", quantity: "2000000" }, + { unit: "cccc0011", quantity: "5" }, + ]); + expect(utxos[0]!.output.dataHash).toBe("bb".repeat(32)); + expect(utxos[0]!.output.plutusData).toBe("d87980"); + }); + + it("maps account info", async () => { + const provider = makeProvider(async (url: string) => { + expect(url).toContain("account/"); + return { + status: 200, + data: { + poolId: "pool1abc", + active: true, + controlledAmount: "10000000", + withdrawableAmount: "1000", + withdrawalsSum: "500", + }, + }; + }); + + const info = await provider.fetchAccountInfo("stake_test1xyz"); + expect(info).toEqual({ + poolId: "pool1abc", + active: true, + balance: "10000000", + rewards: "1000", + withdrawals: "500", + }); + }); + + it("maps transaction UTxOs (snake_case) filtered by index", async () => { + const provider = makeProvider(async () => ({ + status: 200, + data: { + hash: "dd".repeat(32), + outputs: [ + { + tx_hash: "dd".repeat(32), + output_index: 0, + owner_addr: "addr_test1a", + amounts: [{ unit: "lovelace", quantity: "1000000" }], + }, + { + tx_hash: "dd".repeat(32), + output_index: 1, + owner_addr: "addr_test1b", + amounts: [{ unit: "lovelace", quantity: "2000000" }], + }, + ], + }, + })); + + const utxos = await provider.fetchUTxOs("dd".repeat(32), 1); + expect(utxos).toHaveLength(1); + expect(utxos[0]!.input.outputIndex).toBe(1); + expect(utxos[0]!.output.address).toBe("addr_test1b"); + }); + + it("submits a transaction as text/plain and returns the hash", async () => { + const post = jest.fn(async (_url: string, _body: any, config: any) => { + expect(config.headers["Content-Type"]).toBe("text/plain"); + return { status: 200, data: "ee".repeat(32) }; + }); + const provider = makeProvider( + async () => ({ status: 200, data: {} }), + post, + ); + + const hash = await provider.submitTx("00ff"); + expect(hash).toBe("ee".repeat(32)); + expect(post).toHaveBeenCalledWith( + "transactions/submit", + "00ff", + expect.anything(), + ); + }); +});