diff --git a/README.md b/README.md index 81d8519..475312f 100644 --- a/README.md +++ b/README.md @@ -299,19 +299,30 @@ it, lives in your local signer instead - see `VECTOR_SIGNER_SPEND_LIMIT_PER_TX` > echoes a token value. Commas delimit entries and cannot be escaped, so generate > tokens from a comma-free charset (hex / base64url / alphanumeric). -> **Error responses do not echo configured endpoints.** A failed Ogmios or Koios -> query is this server's own infrastructure problem, not something the caller can -> act on - it is reported by service name and status only, for example "Ogmios -> request failed (queryLedgerState/utxo): 503 Service Unavailable". A rejected +> **Error responses do not echo configured endpoints.** This covers every failure +> mode a network call can produce, not just an HTTP-level rejection: a DNS +> failure, a dropped connection, and a malformed (non-JSON) response body are all +> sanitised the same way an ordinary `4xx`/`5xx` status is - the underlying HTTP +> client is the platform's native `fetch`, chosen in part because its own errors +> do not embed the request URL the way some polyfills do. A failed Ogmios or +> Koios query is this server's own infrastructure problem, not something the +> caller can act on - it is reported by service name and status only, for +> example "Ogmios request failed (queryLedgerState/utxo): 503 Service +> Unavailable" or, for a connection failure, "...: network error". A rejected > transaction submission is different: the ledger's verdict on the CALLER'S OWN > transaction (a bad input, an unmet script condition, a fee too small) stays in > the response, since it is the feedback loop an agent needs to self-correct after > build → sign → submit - only URL-shaped content is scrubbed out of it first, for > example "Transaction submission rejected (400 Bad Request): -> ValueNotConservedUTxO...". Neither case ever echoes the configured -> `VECTOR_OGMIOS_URL` / `VECTOR_KOIOS_URL` / `VECTOR_SUBMIT_URL` value itself, and -> full unscrubbed detail always goes to the server's own `console.error` log for -> operators. +> ValueNotConservedUTxO...". A NETWORK failure to even reach the submit API is +> reported as an infrastructure failure instead ("...: network error"), never +> worded as a ledger rejection, since nothing looked at the transaction at all. +> Neither case ever echoes the configured `VECTOR_OGMIOS_URL` / `VECTOR_KOIOS_URL` +> / `VECTOR_SUBMIT_URL` value itself, and full unscrubbed detail always goes to +> the server's own `console.error` log for operators. **Known limit:** the +> submission-rejection scrub removes http(s) URLs and the three endpoints above +> by name - a rejection body that happens to name some other internal hostname +> passes through unscrubbed, bounded by a 2000-character cap. ### Mainnet endpoints diff --git a/package-lock.json b/package-lock.json index 4bccb47..b6faaa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2616,15 +2616,6 @@ "sha.js": "^2.4.8" } }, - "node_modules/cross-fetch": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", - "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3626,26 +3617,6 @@ "node": ">= 0.6" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-gyp-build-optional-packages": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", @@ -4471,12 +4442,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5263,22 +5228,6 @@ "@zxing/text-encoding": "0.9.0" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5347,7 +5296,6 @@ "@lucid-evolution/lucid": "^0.4.29", "@modelcontextprotocol/sdk": "^1.4.1", "@noble/hashes": "^1.7.1", - "cross-fetch": "^4.0.0", "dotenv": "^16.4.5", "zod": "^3.24.1" }, @@ -5366,8 +5314,7 @@ "version": "0.1.0", "dependencies": { "@lucid-evolution/lucid": "^0.4.29", - "@noble/hashes": "^1.7.1", - "cross-fetch": "^4.0.0" + "@noble/hashes": "^1.7.1" }, "engines": { "node": ">=22" diff --git a/packages/builder/package.json b/packages/builder/package.json index 6579c30..44395af 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -19,7 +19,6 @@ "@lucid-evolution/lucid": "^0.4.29", "@modelcontextprotocol/sdk": "^1.4.1", "@noble/hashes": "^1.7.1", - "cross-fetch": "^4.0.0", "dotenv": "^16.4.5", "zod": "^3.24.1" }, diff --git a/packages/shared/package.json b/packages/shared/package.json index 2c52fce..d4b78f3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,8 +11,7 @@ }, "dependencies": { "@lucid-evolution/lucid": "^0.4.29", - "@noble/hashes": "^1.7.1", - "cross-fetch": "^4.0.0" + "@noble/hashes": "^1.7.1" }, "engines": { "node": ">=22" diff --git a/packages/shared/src/provider.ts b/packages/shared/src/provider.ts index 96e91eb..75c8f63 100644 --- a/packages/shared/src/provider.ts +++ b/packages/shared/src/provider.ts @@ -3,8 +3,23 @@ * * Implements the @lucid-evolution/lucid Provider interface so we can use Lucid's * transaction building/signing without depending on Blockfrost. + * + * Uses the global `fetch` (native, undici-backed - guaranteed since the node + * floor is >=22), not a bundled polyfill. This used to import `fetch` from + * `cross-fetch`, whose underlying `node-fetch` embeds the request URL + * directly in its own thrown error messages (`request to ${url} failed, + * reason: ...`, `invalid json response body at ${url} reason: ...`) - a + * leak that bypassed every sanitiser below it, since those only handle the + * `!response.ok` case, not a fetch()-level rejection or a response.json() + * parse failure. Undici's errors do not embed the URL (verified live on this + * Node build: a DNS failure throws `TypeError: fetch failed` with the real + * reason on `.cause`, not in `.message`), but every fetch() call and every + * `.json()` parse is still wrapped below regardless - a JSON-parse failure + * leaks a snippet of the raw body into its own SyntaxError message + * (`Unexpected token '<', "") - without this, the operator's console.error would show only the + * useless top-level message. + */ +function describeError(err: unknown): string { + if (err instanceof Error) { + const cause = (err as { cause?: unknown }).cause; + const causeText = cause instanceof Error ? cause.message : cause !== undefined ? String(cause) : undefined; + return causeText ? `${err.message} (cause: ${causeText})` : err.message; + } + return String(err); +} + export class OgmiosProvider implements Provider { private ogmiosUrl: string; private submitUrl: string; @@ -183,19 +240,44 @@ export class OgmiosProvider implements Provider { } /** - * Send a JSON-RPC request to Ogmios + * The three configured endpoints, for scrubUrls' knownHosts parameter - + * defensive: a shared reverse proxy in front of more than one of these + * services could in principle echo a SIBLING service's hostname in its own + * error page, so every caller-visible scrub pass considers all three, not + * just the one endpoint actually being called. + */ + private knownHosts(): string[] { + return [this.ogmiosUrl, this.submitUrl, this.koiosUrl].filter((u): u is string => Boolean(u)); + } + + /** + * Send a JSON-RPC request to Ogmios. + * + * Every failure mode a bare `await fetch(...)` can produce is caught here + * and rethrown sanitised: a rejected fetch (network/DNS failure - the + * class of error node-fetch used to embed this.ogmiosUrl into, see the + * file header), a non-OK HTTP response, a response body that is not valid + * JSON, and a well-formed JSON-RPC error object. Full detail (including + * the URL) always goes to console.error first; only a sanitised summary is + * ever thrown. */ private async rpc(method: string, params?: unknown): Promise { - const response = await fetch(this.ogmiosUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - method, - params: params || {}, - id: null, - }), - }); + let response: Response; + try { + response = await fetch(this.ogmiosUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method, + params: params || {}, + id: null, + }), + }); + } catch (err) { + console.error(`[OgmiosProvider] Ogmios network error (${method}) at ${this.ogmiosUrl}: ${describeError(err)}`); + throw new Error(formatServiceError('Ogmios', method, 'network error')); + } if (!response.ok) { const text = await response.text(); @@ -203,7 +285,14 @@ export class OgmiosProvider implements Provider { throw new Error(formatServiceError('Ogmios', method, response.status, response.statusText)); } - const json = await response.json(); + let json: any; + try { + json = await response.json(); + } catch (err) { + console.error(`[OgmiosProvider] Ogmios response parse error (${method}) at ${this.ogmiosUrl}: ${describeError(err)}`); + throw new Error(formatServiceError('Ogmios', method, 'invalid response')); + } + if (json.error) { console.error(`[OgmiosProvider] Ogmios RPC error (${method}) at ${this.ogmiosUrl}:`, json.error); throw new Error(formatServiceError('Ogmios', method, json.error?.code ?? 'error', json.error?.message ?? 'RPC error')); @@ -298,7 +387,11 @@ export class OgmiosProvider implements Provider { const policyId = unit.slice(0, 56); const assetName = unit.slice(56); - // Try Koios indexer first (fastest path) + // Try Koios indexer first (fastest path). Every failure here - network, + // non-OK, or unparseable body - is logged for operators and then falls + // through to the generic "not found" throw below, which names none of + // it: the caller only ever learns the asset was not found, not why the + // Koios attempt failed. if (this.koiosUrl) { try { const response = await fetch(`${this.koiosUrl}/api/v1/asset_utxos`, { @@ -311,9 +404,11 @@ export class OgmiosProvider implements Provider { if (data.length > 0) { return this.koiosUtxoToLucid(data[0]); } + } else { + console.error(`[OgmiosProvider] Koios asset_utxos query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`); } } catch (err) { - // Koios unreachable - caller can fall back to scanning UTxOs + console.error(`[OgmiosProvider] Koios asset_utxos error at ${this.koiosUrl}: ${describeError(err)}`); } } @@ -353,7 +448,8 @@ export class OgmiosProvider implements Provider { } async getDelegation(rewardAddress: string): Promise { - // Try Koios first if available + // Try Koios first if available - same swallow-to-default shape as + // getUtxoByUnit above, now with operator visibility on the way in. if (this.koiosUrl) { try { const response = await fetch(`${this.koiosUrl}/api/v1/account_info`, { @@ -369,16 +465,18 @@ export class OgmiosProvider implements Provider { rewards: BigInt(data[0].rewards_available || 0), }; } + } else { + console.error(`[OgmiosProvider] Koios account_info query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`); } - } catch { - // Fall through to default + } catch (err) { + console.error(`[OgmiosProvider] Koios account_info error at ${this.koiosUrl}: ${describeError(err)}`); } } return { poolId: null, rewards: 0n }; } async getDatum(datumHash: string): Promise { - // Try Koios if available + // Try Koios if available - same shape again. if (this.koiosUrl) { try { const response = await fetch(`${this.koiosUrl}/api/v1/datum_info`, { @@ -391,9 +489,11 @@ export class OgmiosProvider implements Provider { if (data.length > 0 && data[0].bytes) { return data[0].bytes; } + } else { + console.error(`[OgmiosProvider] Koios datum_info query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`); } - } catch { - // Fall through + } catch (err) { + console.error(`[OgmiosProvider] Koios datum_info error at ${this.koiosUrl}: ${describeError(err)}`); } } throw new Error(`Datum not found for hash: ${datumHash}. Datum lookups require Koios indexer.`); @@ -406,11 +506,11 @@ export class OgmiosProvider implements Provider { * `koiosIndicatesConfirmed` above, kept separate so it can be unit tested * without a network round trip. Unlike the Koios branch inside `awaitTx` * below (which swallows every failure into "keep polling"), this throws on - * a network failure or a non-OK response: the caller could not determine - * status, which is a different situation from "asked, and it is not - * confirmed yet", and callers such as `pollUntilConfirmed` + * a network failure, a non-OK response, or an unparseable body: the caller + * could not determine status, which is a different situation from "asked, + * and it is not confirmed yet", and callers such as `pollUntilConfirmed` * (packages/builder/src/vector/poll.ts) need to be able to tell the two - * apart. + * apart. Every throw here is sanitised the same way rpc()'s is. */ async getKoiosTxStatus(txHash: string): Promise { if (!this.koiosUrl) { @@ -420,17 +520,28 @@ export class OgmiosProvider implements Provider { ); } - const response = await fetch(`${this.koiosUrl}/api/v1/tx_status`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ _tx_hashes: [txHash] }), - }); + let response: Response; + try { + response = await fetch(`${this.koiosUrl}/api/v1/tx_status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ _tx_hashes: [txHash] }), + }); + } catch (err) { + console.error(`[OgmiosProvider] Koios network error (tx_status) at ${this.koiosUrl}: ${describeError(err)}`); + throw new Error(formatServiceError('Koios', 'tx_status', 'network error')); + } if (!response.ok) { const text = await response.text(); console.error(`[OgmiosProvider] Koios tx_status query failed at ${this.koiosUrl}: ${response.status} ${response.statusText} - ${text}`); throw new Error(formatServiceError('Koios', 'tx_status', response.status, response.statusText)); } - return response.json(); + try { + return await response.json(); + } catch (err) { + console.error(`[OgmiosProvider] Koios response parse error (tx_status) at ${this.koiosUrl}: ${describeError(err)}`); + throw new Error(formatServiceError('Koios', 'tx_status', 'invalid response')); + } } /** @@ -444,7 +555,9 @@ export class OgmiosProvider implements Provider { * indistinguishable from "checked and it is not there yet". Left * unmodified rather than deleted or reworked: TypeScript requires it to * satisfy `implements Provider`, and changing its own retry/error - * semantics is out of scope here since nothing depends on them. + * semantics is out of scope here since nothing depends on them. The two + * catch blocks now log for operator visibility (they did not before); + * the swallow-and-continue-polling behavior itself is unchanged. */ async awaitTx(txHash: string, checkInterval: number = 3000): Promise { // Poll for tx confirmation @@ -466,8 +579,8 @@ export class OgmiosProvider implements Provider { return true; } } - } catch { - // Continue polling + } catch (err) { + console.error(`[OgmiosProvider] awaitTx Koios poll error at ${this.koiosUrl}: ${describeError(err)}`); } } else { // Without Koios, try querying the UTxO by outRef @@ -476,8 +589,8 @@ export class OgmiosProvider implements Provider { if (utxos.length > 0) { return true; } - } catch { - // Not yet confirmed, continue + } catch (err) { + console.error(`[OgmiosProvider] awaitTx getUtxosByOutRef poll error: ${describeError(err)}`); } } } @@ -518,6 +631,15 @@ export class OgmiosProvider implements Provider { /** * Get transaction history for an address via Koios. + * + * Step 1 (address_txs) is load-bearing: any failure - network, non-OK, or + * unparseable body - is sanitised and thrown, same shape as rpc()/ + * getKoiosTxStatus(). Step 2 (tx_info) is optional detail on top of a + * result Step 1 already produced: any failure there - network, non-OK, or + * unparseable body - degrades to the basic tx list instead of failing the + * whole call, extending what used to be only a `!response.ok` fallback to + * cover every failure mode, with operator logging on each path (none of + * which existed before this change). */ async getTransactionHistory( address: string, @@ -532,11 +654,17 @@ export class OgmiosProvider implements Provider { } // Step 1: Get tx hashes for the address - const txListResponse = await fetch(`${this.koiosUrl}/api/v1/address_txs`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ _addresses: [address], _after_block_height: 0 }), - }); + let txListResponse: Response; + try { + txListResponse = await fetch(`${this.koiosUrl}/api/v1/address_txs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ _addresses: [address], _after_block_height: 0 }), + }); + } catch (err) { + console.error(`[OgmiosProvider] Koios network error (address_txs) at ${this.koiosUrl}: ${describeError(err)}`); + throw new Error(formatServiceError('Koios', 'address_txs', 'network error')); + } if (!txListResponse.ok) { const text = await txListResponse.text(); @@ -544,7 +672,13 @@ export class OgmiosProvider implements Provider { throw new Error(formatServiceError('Koios', 'address_txs', txListResponse.status, txListResponse.statusText)); } - const txList: any[] = await txListResponse.json(); + let txList: any[]; + try { + txList = await txListResponse.json(); + } catch (err) { + console.error(`[OgmiosProvider] Koios response parse error (address_txs) at ${this.koiosUrl}: ${describeError(err)}`); + throw new Error(formatServiceError('Koios', 'address_txs', 'invalid response')); + } // Apply pagination const paginatedTxs = txList.slice(offset, offset + limit); @@ -553,27 +687,43 @@ export class OgmiosProvider implements Provider { return []; } - // Step 2: Fetch full tx details - const txHashes = paginatedTxs.map((tx: any) => tx.tx_hash); - const txInfoResponse = await fetch(`${this.koiosUrl}/api/v1/tx_info`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ _tx_hashes: txHashes }), - }); + const basicTxList = () => paginatedTxs.map((tx: any) => ({ + txHash: tx.tx_hash, + blockHeight: tx.block_height || 0, + blockTime: tx.block_time + ? new Date(tx.block_time * 1000).toISOString() + : '', + fee: '0', + })); - if (!txInfoResponse.ok) { - // Fall back to basic tx list without details - return paginatedTxs.map((tx: any) => ({ - txHash: tx.tx_hash, - blockHeight: tx.block_height || 0, - blockTime: tx.block_time - ? new Date(tx.block_time * 1000).toISOString() - : '', - fee: '0', - })); + // Step 2: Fetch full tx details - optional enrichment, falls back to the + // basic list above on any failure rather than failing the whole call. + const txHashes = paginatedTxs.map((tx: any) => tx.tx_hash); + let txInfoResponse: Response; + try { + txInfoResponse = await fetch(`${this.koiosUrl}/api/v1/tx_info`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ _tx_hashes: txHashes }), + }); + } catch (err) { + console.error(`[OgmiosProvider] Koios network error (tx_info) at ${this.koiosUrl}: ${describeError(err)} - falling back to basic tx list`); + return basicTxList(); } - const txInfos: any[] = await txInfoResponse.json(); + if (!txInfoResponse.ok) { + const text = await txInfoResponse.text().catch(() => ''); + console.error(`[OgmiosProvider] Koios tx_info query failed at ${this.koiosUrl}: ${txInfoResponse.status} ${txInfoResponse.statusText} - ${text} - falling back to basic tx list`); + return basicTxList(); + } + + let txInfos: any[]; + try { + txInfos = await txInfoResponse.json(); + } catch (err) { + console.error(`[OgmiosProvider] Koios response parse error (tx_info) at ${this.koiosUrl}: ${describeError(err)} - falling back to basic tx list`); + return basicTxList(); + } return txInfos.map((info: any) => ({ txHash: info.tx_hash, @@ -595,16 +745,34 @@ export class OgmiosProvider implements Provider { })); } + /** + * A NETWORK failure to reach the submit API (fetch() itself rejects, no + * HTTP response was ever received) is not a ledger verdict on anything - + * it is this server's own infrastructure failing to talk to the submit + * endpoint, the same class of problem as an Ogmios/Koios connection + * failure. It is sanitised through formatServiceError, not + * formatSubmitRejection: calling an unreachable endpoint a "rejection" + * would misleadingly imply the ledger looked at the transaction and said + * no, when nothing looked at it at all. An actual non-OK HTTP response + * from the submit API IS the ledger's verdict, and keeps going through + * formatSubmitRejection exactly as before. + */ async submitTx(tx: string): Promise { // tx is a hex-encoded CBOR transaction // submit-api expects raw CBOR bytes with Content-Type: application/cbor const bytes = Buffer.from(tx, 'hex'); - const response = await fetch(this.submitUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/cbor' }, - body: bytes, - }); + let response: Response; + try { + response = await fetch(this.submitUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/cbor' }, + body: bytes, + }); + } catch (err) { + console.error(`[OgmiosProvider] Submit API network error at ${this.submitUrl}: ${describeError(err)}`); + throw new Error(formatServiceError('Submit API', 'submitTx', 'network error')); + } if (!response.ok) { const errorText = await response.text(); @@ -614,7 +782,7 @@ export class OgmiosProvider implements Provider { response.status, response.statusText, errorText, - [this.ogmiosUrl, this.submitUrl, this.koiosUrl].filter((u): u is string => Boolean(u)), + this.knownHosts(), ), ); } diff --git a/packages/shared/test/provider-fetch-safety.test.ts b/packages/shared/test/provider-fetch-safety.test.ts new file mode 100644 index 0000000..13cfbe4 --- /dev/null +++ b/packages/shared/test/provider-fetch-safety.test.ts @@ -0,0 +1,188 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { OgmiosProvider } from '../src/provider.ts'; + +// The critical this file exists to close: OgmiosProvider used to import +// `fetch` from `cross-fetch` (-> node-fetch@2.7.0), which embeds the +// request URL directly in its own thrown error messages - a leak that +// bypassed every sanitiser in provider-error-format.test.ts, since those +// only cover the `!response.ok` case, never a fetch()-level rejection or a +// response.json() parse failure. Now that provider.ts reads the global +// `fetch`, this file can stub it directly and pin the throw sites for the +// first time - see the docblock at the top of provider.ts for the full +// story, including the live-verified node-fetch source lines and the +// live-verified shape of native fetch's own errors (which do NOT embed the +// URL, but a JSON-parse failure still leaks a body snippet regardless of +// fetch implementation - also live-verified, reproduced in the fixtures +// below). + +const OGMIOS_URL = 'https://ogmios-internal.example.cluster.local:1337'; +const SUBMIT_URL = 'https://submit-internal.example.cluster.local:1338/api/submit/tx'; +const KOIOS_URL = 'https://koios-internal.example.cluster.local:1339'; +const HOSTNAME = 'ogmios-internal.example.cluster.local'; + +function testProvider(): OgmiosProvider { + return new OgmiosProvider({ ogmiosUrl: OGMIOS_URL, submitUrl: SUBMIT_URL, koiosUrl: KOIOS_URL }); +} + +async function withStubbedFetch(impl: typeof fetch, fn: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = impl; + try { + return await fn(); + } finally { + globalThis.fetch = original; + } +} + +// Reproduces the exact shape observed live on this Node build: +// `fetch('http://')` rejects with `TypeError: fetch +// failed`, the real DNS/connection reason attached as `.cause`, NOT folded +// into `.message`. (`Object.assign` instead of the two-arg Error +// constructor - packages/shared's tsconfig targets es2020, whose ambient +// `Error` type has no `cause`-accepting overload; provider.ts's own +// `describeError` reads `.cause` the same untyped way for the same reason.) +function networkErrorFetch(): typeof fetch { + return (async () => { + throw Object.assign(new TypeError('fetch failed'), { + cause: new Error(`getaddrinfo ENOTFOUND ${HOSTNAME}`), + }); + }) as unknown as typeof fetch; +} + +// Reproduces the exact shape observed live: a 200 response whose body is +// HTML (a misconfigured reverse proxy's own error page), not JSON - +// response.json() throws a real SyntaxError quoting a snippet of the body. +function htmlBodyFetch(): typeof fetch { + return (async () => new Response( + `502 Bad Gateway from ${HOSTNAME}`, + { status: 200, headers: { 'Content-Type': 'text/html' } }, + )) as unknown as typeof fetch; +} + +function okJsonFetch(body: unknown): typeof fetch { + return (async () => new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as unknown as typeof fetch; +} + +describe('OgmiosProvider - fetch-level failures never leak the endpoint (audit E, critical)', () => { + test('a network error (rejected fetch) on an Ogmios query yields no URL, no hostname, no raw DNS detail', async () => { + await assert.rejects( + () => withStubbedFetch(networkErrorFetch(), () => testProvider().getNetworkTip()), + (err: Error) => { + assert.ok(!err.message.includes('http://') && !err.message.includes('https://'), `leaked a URL scheme: ${err.message}`); + assert.ok(!err.message.includes(HOSTNAME), `leaked the hostname: ${err.message}`); + assert.ok(!err.message.includes('ENOTFOUND'), `leaked the raw DNS error: ${err.message}`); + assert.equal(err.message, 'Ogmios request failed (queryNetwork/tip): network error'); + return true; + }, + ); + }); + + test('a malformed (HTML) response body on an Ogmios query yields no URL, no body snippet', async () => { + await assert.rejects( + () => withStubbedFetch(htmlBodyFetch(), () => testProvider().getNetworkTip()), + (err: Error) => { + assert.ok(!err.message.includes(''), `leaked the raw body: ${err.message}`); + assert.ok(!err.message.includes(HOSTNAME), `leaked the hostname: ${err.message}`); + assert.equal(err.message, 'Ogmios request failed (queryNetwork/tip): invalid response'); + return true; + }, + ); + }); + + test('an Ogmios JSON-RPC structured error is still sanitised (unaffected by this round, re-pinned here)', async () => { + await assert.rejects( + () => withStubbedFetch(okJsonFetch({ error: { code: -32602, message: 'Invalid params' } }), () => testProvider().getNetworkTip()), + (err: Error) => { + assert.ok(!err.message.includes(HOSTNAME)); + assert.equal(err.message, 'Ogmios request failed (queryNetwork/tip): -32602 Invalid params'); + return true; + }, + ); + }); + + test('a network error on submitTx yields no URL, no hostname, and is NOT worded as a "rejection"', async () => { + await assert.rejects( + () => withStubbedFetch(networkErrorFetch(), () => testProvider().submitTx('ab'.repeat(32))), + (err: Error) => { + assert.ok(!err.message.includes('http://') && !err.message.includes('https://')); + assert.ok(!err.message.includes('cluster.local'), `leaked the hostname: ${err.message}`); + assert.equal(err.message, 'Submit API request failed (submitTx): network error'); + // A network failure never reached the ledger - calling it a + // "rejection" would misleadingly imply a verdict was ever rendered. + assert.ok(!err.message.toLowerCase().includes('rejected'), `network failure worded as a ledger rejection: ${err.message}`); + return true; + }, + ); + }); + + test('a network error on a Koios query (getKoiosTxStatus) yields no URL, no hostname', async () => { + await assert.rejects( + () => withStubbedFetch(networkErrorFetch(), () => testProvider().getKoiosTxStatus('a'.repeat(64))), + (err: Error) => { + assert.ok(!err.message.includes('cluster.local')); + assert.equal(err.message, 'Koios request failed (tx_status): network error'); + return true; + }, + ); + }); + + test('a malformed response body on getKoiosTxStatus yields no URL, no body snippet', async () => { + await assert.rejects( + () => withStubbedFetch(htmlBodyFetch(), () => testProvider().getKoiosTxStatus('a'.repeat(64))), + (err: Error) => { + assert.ok(!err.message.includes('')); + assert.equal(err.message, 'Koios request failed (tx_status): invalid response'); + return true; + }, + ); + }); + + test('a network error on getTransactionHistory (address_txs, the load-bearing call) yields no URL, no hostname', async () => { + await assert.rejects( + () => withStubbedFetch(networkErrorFetch(), () => testProvider().getTransactionHistory('addr1qtest')), + (err: Error) => { + assert.ok(!err.message.includes('cluster.local')); + assert.equal(err.message, 'Koios request failed (address_txs): network error'); + return true; + }, + ); + }); + + test('a Koios failure on a swallow-to-fallback method (getDelegation) degrades quietly, no throw, no leak possible', async () => { + const result = await withStubbedFetch(networkErrorFetch(), () => testProvider().getDelegation('stake1uxxxxxxx')); + assert.deepEqual(result, { poolId: null, rewards: 0n }); + }); + + test('a Koios failure on a swallow-to-fallback method (getUtxoByUnit) still reaches the generic not-found message, no leak', async () => { + await assert.rejects( + () => withStubbedFetch(networkErrorFetch(), () => testProvider().getUtxoByUnit('a'.repeat(56) + 'deadbeef')), + (err: Error) => { + assert.ok(!err.message.includes(HOSTNAME)); + assert.match(err.message, /not found/i); + return true; + }, + ); + }); + + test('the happy path is unaffected: a normal successful response still resolves correctly', async () => { + const tip = await withStubbedFetch( + okJsonFetch({ result: { slot: 12345, id: 'a'.repeat(64) } }), + () => testProvider().getNetworkTip(), + ); + assert.equal(tip.slot, 12345); + assert.equal(tip.hash, 'a'.repeat(64)); + }); + + test('the happy path for submitTx is unaffected: a normal successful response still resolves correctly', async () => { + const hash = 'a'.repeat(64); + const txHash = await withStubbedFetch( + (async () => new Response(hash, { status: 200 })) as unknown as typeof fetch, + () => testProvider().submitTx('ab'.repeat(32)), + ); + assert.equal(txHash, hash); + }); +});