fix: stop fetch-level errors from echoing endpoint URLs; use native fetch now that the node floor is 22

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
FilipBl4gojevic
2026-07-31 19:10:42 +02:00
parent e866b05153
commit c263794879
6 changed files with 447 additions and 135 deletions

View File

@@ -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

55
package-lock.json generated
View File

@@ -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"

View File

@@ -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"
},

View File

@@ -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"

View File

@@ -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 '<', "<html><bod"... is not valid JSON`, verified live
* too) independent of which fetch implementation is in use, and operators
* still need the detail this file's console.error calls carry.
*/
import fetch from 'cross-fetch';
import type {
Provider,
ProtocolParameters,
@@ -24,9 +39,13 @@ interface OgmiosProviderConfig {
/**
* Interpret a submit-api response body as a transaction hash.
*
* Exported and pure so it can be tested without a network round trip — the
* class method that calls it cannot be, because provider.ts imports `fetch`
* from cross-fetch rather than reading the global.
* Exported and pure so it can be tested without a network round trip. Now
* that `provider.ts` reads the global `fetch` instead of importing one from
* `cross-fetch`, `OgmiosProvider`'s own methods are also stubbable in tests
* (`globalThis.fetch = ...`) - `parseSubmitResponse` stays a separate pure
* function anyway, on its own merits: it is reused nowhere near a network
* call, and testing the parsing rule in isolation is simpler than testing it
* through a live or stubbed HTTP round trip either way.
*
* Refuses rather than guessing. The previous implementation fell back to the
* first 64 characters of the *submitted CBOR*, which is a well-formed 64-char
@@ -90,7 +109,8 @@ export function koiosIndicatesConfirmed(data: unknown): boolean {
* that has no business reaching a caller. Call sites console.error the full
* detail (status, body, and which endpoint) themselves before throwing this -
* full detail to the operator's log, a clean summary to the caller. That
* split is the sanitisation.
* split is the sanitisation. `status` also accepts a string, for a network
* failure or a JSON-RPC error code where there is no HTTP status at all.
*
* Exported and pure so it can be tested without a network round trip - same
* rationale as parseSubmitResponse above.
@@ -123,6 +143,18 @@ function escapeRegExp(s: string): string {
* https?:// pattern alone would miss. Caps the result length so a large
* upstream error page cannot flood a tool response.
*
* Known limitation, stated plainly rather than left for a reader to
* discover: this only removes http(s) URLs and whatever hostnames the
* caller passes in as `knownHosts` (this file always passes the three
* configured Ogmios/Koios/submit-API hosts). A submission-rejection body
* that happens to name a DIFFERENT internal host - one this server was
* never configured to talk to - passes through unscrubbed, bounded only by
* the 2000-character cap above. Closing that fully would mean pattern-
* matching arbitrary hostname shapes out of free text, which risks
* mangling a legitimate ledger-error string; the three known hosts cover
* every endpoint this server itself is configured to reach, which is the
* actual leak surface audit E is about.
*
* Exported and pure so it can be tested without a network round trip - same
* rationale as parseSubmitResponse above.
*/
@@ -159,6 +191,12 @@ export function scrubUrls(text: string, knownHosts: string[] = []): string {
* discarded outright: the body is still attacker-adjacent (an internal or
* misconfigured endpoint's own response), even though its content is
* otherwise legitimate to return.
*
* A NETWORK failure to even reach the submit API (no HTTP response at all)
* is deliberately NOT routed through this function - see submitTx below. It
* is not a ledger verdict on anything, and calling it a "rejection" would be
* actively misleading; it goes through formatServiceError instead, the same
* as an Ogmios/Koios connection failure.
*/
export function formatSubmitRejection(
status: number,
@@ -170,6 +208,25 @@ export function formatSubmitRejection(
return `Transaction submission rejected (${status}${suffix}): ${scrubUrls(body, knownHosts)}`;
}
/**
* Render an Error (or thrown non-Error value) into one log-friendly string,
* including its `.cause` when present - operator-log use only, never for a
* caller-visible message. Native fetch (undici) throws are often terse
* ("fetch failed") with the actual DNS/connection reason attached as
* `.cause` rather than folded into `.message` (verified live: a DNS failure
* yields message "fetch failed", cause.message "getaddrinfo ENOTFOUND
* <host>") - 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,10 +240,31 @@ 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<any> {
const response = await fetch(this.ogmiosUrl, {
let response: Response;
try {
response = await fetch(this.ogmiosUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -196,6 +274,10 @@ export class OgmiosProvider implements Provider {
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<Delegation> {
// 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<string> {
// 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<unknown> {
if (!this.koiosUrl) {
@@ -420,17 +520,28 @@ export class OgmiosProvider implements Provider {
);
}
const response = await fetch(`${this.koiosUrl}/api/v1/tx_status`, {
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<boolean> {
// 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`, {
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,17 +687,7 @@ 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 }),
});
if (!txInfoResponse.ok) {
// Fall back to basic tx list without details
return paginatedTxs.map((tx: any) => ({
const basicTxList = () => paginatedTxs.map((tx: any) => ({
txHash: tx.tx_hash,
blockHeight: tx.block_height || 0,
blockTime: tx.block_time
@@ -571,9 +695,35 @@ export class OgmiosProvider implements Provider {
: '',
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<string> {
// 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, {
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(),
),
);
}

View File

@@ -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<T>(impl: typeof fetch, fn: () => Promise<T>): Promise<T> {
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://<unreachable host>')` 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(
`<html><body>502 Bad Gateway from ${HOSTNAME}</body></html>`,
{ 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('<html>'), `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('<html>'));
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);
});
});