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:
27
README.md
27
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
|
||||
|
||||
|
||||
55
package-lock.json
generated
55
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
188
packages/shared/test/provider-fetch-safety.test.ts
Normal file
188
packages/shared/test/provider-fetch-safety.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user