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
|
> echoes a token value. Commas delimit entries and cannot be escaped, so generate
|
||||||
> tokens from a comma-free charset (hex / base64url / alphanumeric).
|
> tokens from a comma-free charset (hex / base64url / alphanumeric).
|
||||||
|
|
||||||
> **Error responses do not echo configured endpoints.** A failed Ogmios or Koios
|
> **Error responses do not echo configured endpoints.** This covers every failure
|
||||||
> query is this server's own infrastructure problem, not something the caller can
|
> mode a network call can produce, not just an HTTP-level rejection: a DNS
|
||||||
> act on - it is reported by service name and status only, for example "Ogmios
|
> failure, a dropped connection, and a malformed (non-JSON) response body are all
|
||||||
> request failed (queryLedgerState/utxo): 503 Service Unavailable". A rejected
|
> 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 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
|
> 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
|
> 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
|
> build → sign → submit - only URL-shaped content is scrubbed out of it first, for
|
||||||
> example "Transaction submission rejected (400 Bad Request):
|
> example "Transaction submission rejected (400 Bad Request):
|
||||||
> ValueNotConservedUTxO...". Neither case ever echoes the configured
|
> ValueNotConservedUTxO...". A NETWORK failure to even reach the submit API is
|
||||||
> `VECTOR_OGMIOS_URL` / `VECTOR_KOIOS_URL` / `VECTOR_SUBMIT_URL` value itself, and
|
> reported as an infrastructure failure instead ("...: network error"), never
|
||||||
> full unscrubbed detail always goes to the server's own `console.error` log for
|
> worded as a ledger rejection, since nothing looked at the transaction at all.
|
||||||
> operators.
|
> 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
|
### Mainnet endpoints
|
||||||
|
|
||||||
|
|||||||
55
package-lock.json
generated
55
package-lock.json
generated
@@ -2616,15 +2616,6 @@
|
|||||||
"sha.js": "^2.4.8"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3626,26 +3617,6 @@
|
|||||||
"node": ">= 0.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": {
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz",
|
"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": ">=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": {
|
"node_modules/tree-kill": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
@@ -5263,22 +5228,6 @@
|
|||||||
"@zxing/text-encoding": "0.9.0"
|
"@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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -5347,7 +5296,6 @@
|
|||||||
"@lucid-evolution/lucid": "^0.4.29",
|
"@lucid-evolution/lucid": "^0.4.29",
|
||||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
"@noble/hashes": "^1.7.1",
|
"@noble/hashes": "^1.7.1",
|
||||||
"cross-fetch": "^4.0.0",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
@@ -5366,8 +5314,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucid-evolution/lucid": "^0.4.29",
|
"@lucid-evolution/lucid": "^0.4.29",
|
||||||
"@noble/hashes": "^1.7.1",
|
"@noble/hashes": "^1.7.1"
|
||||||
"cross-fetch": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"@lucid-evolution/lucid": "^0.4.29",
|
"@lucid-evolution/lucid": "^0.4.29",
|
||||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||||
"@noble/hashes": "^1.7.1",
|
"@noble/hashes": "^1.7.1",
|
||||||
"cross-fetch": "^4.0.0",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucid-evolution/lucid": "^0.4.29",
|
"@lucid-evolution/lucid": "^0.4.29",
|
||||||
"@noble/hashes": "^1.7.1",
|
"@noble/hashes": "^1.7.1"
|
||||||
"cross-fetch": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
@@ -3,8 +3,23 @@
|
|||||||
*
|
*
|
||||||
* Implements the @lucid-evolution/lucid Provider interface so we can use Lucid's
|
* Implements the @lucid-evolution/lucid Provider interface so we can use Lucid's
|
||||||
* transaction building/signing without depending on Blockfrost.
|
* 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 {
|
import type {
|
||||||
Provider,
|
Provider,
|
||||||
ProtocolParameters,
|
ProtocolParameters,
|
||||||
@@ -24,9 +39,13 @@ interface OgmiosProviderConfig {
|
|||||||
/**
|
/**
|
||||||
* Interpret a submit-api response body as a transaction hash.
|
* Interpret a submit-api response body as a transaction hash.
|
||||||
*
|
*
|
||||||
* Exported and pure so it can be tested without a network round trip — the
|
* Exported and pure so it can be tested without a network round trip. Now
|
||||||
* class method that calls it cannot be, because provider.ts imports `fetch`
|
* that `provider.ts` reads the global `fetch` instead of importing one from
|
||||||
* from cross-fetch rather than reading the global.
|
* `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
|
* 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
|
* 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
|
* that has no business reaching a caller. Call sites console.error the full
|
||||||
* detail (status, body, and which endpoint) themselves before throwing this -
|
* detail (status, body, and which endpoint) themselves before throwing this -
|
||||||
* full detail to the operator's log, a clean summary to the caller. That
|
* 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
|
* Exported and pure so it can be tested without a network round trip - same
|
||||||
* rationale as parseSubmitResponse above.
|
* 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
|
* https?:// pattern alone would miss. Caps the result length so a large
|
||||||
* upstream error page cannot flood a tool response.
|
* 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
|
* Exported and pure so it can be tested without a network round trip - same
|
||||||
* rationale as parseSubmitResponse above.
|
* 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
|
* discarded outright: the body is still attacker-adjacent (an internal or
|
||||||
* misconfigured endpoint's own response), even though its content is
|
* misconfigured endpoint's own response), even though its content is
|
||||||
* otherwise legitimate to return.
|
* 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(
|
export function formatSubmitRejection(
|
||||||
status: number,
|
status: number,
|
||||||
@@ -170,6 +208,25 @@ export function formatSubmitRejection(
|
|||||||
return `Transaction submission rejected (${status}${suffix}): ${scrubUrls(body, knownHosts)}`;
|
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 {
|
export class OgmiosProvider implements Provider {
|
||||||
private ogmiosUrl: string;
|
private ogmiosUrl: string;
|
||||||
private submitUrl: 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<any> {
|
private async rpc(method: string, params?: unknown): Promise<any> {
|
||||||
const response = await fetch(this.ogmiosUrl, {
|
let response: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
response = await fetch(this.ogmiosUrl, {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
jsonrpc: '2.0',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
method,
|
body: JSON.stringify({
|
||||||
params: params || {},
|
jsonrpc: '2.0',
|
||||||
id: null,
|
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) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -203,7 +285,14 @@ export class OgmiosProvider implements Provider {
|
|||||||
throw new Error(formatServiceError('Ogmios', method, response.status, response.statusText));
|
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) {
|
if (json.error) {
|
||||||
console.error(`[OgmiosProvider] Ogmios RPC error (${method}) at ${this.ogmiosUrl}:`, 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'));
|
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 policyId = unit.slice(0, 56);
|
||||||
const assetName = unit.slice(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) {
|
if (this.koiosUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.koiosUrl}/api/v1/asset_utxos`, {
|
const response = await fetch(`${this.koiosUrl}/api/v1/asset_utxos`, {
|
||||||
@@ -311,9 +404,11 @@ export class OgmiosProvider implements Provider {
|
|||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
return this.koiosUtxoToLucid(data[0]);
|
return this.koiosUtxoToLucid(data[0]);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`[OgmiosProvider] Koios asset_utxos query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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> {
|
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) {
|
if (this.koiosUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.koiosUrl}/api/v1/account_info`, {
|
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),
|
rewards: BigInt(data[0].rewards_available || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`[OgmiosProvider] Koios account_info query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Fall through to default
|
console.error(`[OgmiosProvider] Koios account_info error at ${this.koiosUrl}: ${describeError(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { poolId: null, rewards: 0n };
|
return { poolId: null, rewards: 0n };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDatum(datumHash: string): Promise<string> {
|
async getDatum(datumHash: string): Promise<string> {
|
||||||
// Try Koios if available
|
// Try Koios if available - same shape again.
|
||||||
if (this.koiosUrl) {
|
if (this.koiosUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.koiosUrl}/api/v1/datum_info`, {
|
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) {
|
if (data.length > 0 && data[0].bytes) {
|
||||||
return data[0].bytes;
|
return data[0].bytes;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`[OgmiosProvider] Koios datum_info query failed at ${this.koiosUrl}: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Fall through
|
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.`);
|
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
|
* `koiosIndicatesConfirmed` above, kept separate so it can be unit tested
|
||||||
* without a network round trip. Unlike the Koios branch inside `awaitTx`
|
* without a network round trip. Unlike the Koios branch inside `awaitTx`
|
||||||
* below (which swallows every failure into "keep polling"), this throws on
|
* below (which swallows every failure into "keep polling"), this throws on
|
||||||
* a network failure or a non-OK response: the caller could not determine
|
* a network failure, a non-OK response, or an unparseable body: the caller
|
||||||
* status, which is a different situation from "asked, and it is not
|
* could not determine status, which is a different situation from "asked,
|
||||||
* confirmed yet", and callers such as `pollUntilConfirmed`
|
* 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
|
* (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> {
|
async getKoiosTxStatus(txHash: string): Promise<unknown> {
|
||||||
if (!this.koiosUrl) {
|
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;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
response = await fetch(`${this.koiosUrl}/api/v1/tx_status`, {
|
||||||
body: JSON.stringify({ _tx_hashes: [txHash] }),
|
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) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
console.error(`[OgmiosProvider] Koios tx_status query failed at ${this.koiosUrl}: ${response.status} ${response.statusText} - ${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));
|
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
|
* indistinguishable from "checked and it is not there yet". Left
|
||||||
* unmodified rather than deleted or reworked: TypeScript requires it to
|
* unmodified rather than deleted or reworked: TypeScript requires it to
|
||||||
* satisfy `implements Provider`, and changing its own retry/error
|
* 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> {
|
async awaitTx(txHash: string, checkInterval: number = 3000): Promise<boolean> {
|
||||||
// Poll for tx confirmation
|
// Poll for tx confirmation
|
||||||
@@ -466,8 +579,8 @@ export class OgmiosProvider implements Provider {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Continue polling
|
console.error(`[OgmiosProvider] awaitTx Koios poll error at ${this.koiosUrl}: ${describeError(err)}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Without Koios, try querying the UTxO by outRef
|
// Without Koios, try querying the UTxO by outRef
|
||||||
@@ -476,8 +589,8 @@ export class OgmiosProvider implements Provider {
|
|||||||
if (utxos.length > 0) {
|
if (utxos.length > 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Not yet confirmed, continue
|
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.
|
* 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(
|
async getTransactionHistory(
|
||||||
address: string,
|
address: string,
|
||||||
@@ -532,11 +654,17 @@ export class OgmiosProvider implements Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Get tx hashes for the address
|
// Step 1: Get tx hashes for the address
|
||||||
const txListResponse = await fetch(`${this.koiosUrl}/api/v1/address_txs`, {
|
let txListResponse: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
txListResponse = await fetch(`${this.koiosUrl}/api/v1/address_txs`, {
|
||||||
body: JSON.stringify({ _addresses: [address], _after_block_height: 0 }),
|
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) {
|
if (!txListResponse.ok) {
|
||||||
const text = await txListResponse.text();
|
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));
|
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
|
// Apply pagination
|
||||||
const paginatedTxs = txList.slice(offset, offset + limit);
|
const paginatedTxs = txList.slice(offset, offset + limit);
|
||||||
@@ -553,27 +687,43 @@ export class OgmiosProvider implements Provider {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Fetch full tx details
|
const basicTxList = () => paginatedTxs.map((tx: any) => ({
|
||||||
const txHashes = paginatedTxs.map((tx: any) => tx.tx_hash);
|
txHash: tx.tx_hash,
|
||||||
const txInfoResponse = await fetch(`${this.koiosUrl}/api/v1/tx_info`, {
|
blockHeight: tx.block_height || 0,
|
||||||
method: 'POST',
|
blockTime: tx.block_time
|
||||||
headers: { 'Content-Type': 'application/json' },
|
? new Date(tx.block_time * 1000).toISOString()
|
||||||
body: JSON.stringify({ _tx_hashes: txHashes }),
|
: '',
|
||||||
});
|
fee: '0',
|
||||||
|
}));
|
||||||
|
|
||||||
if (!txInfoResponse.ok) {
|
// Step 2: Fetch full tx details - optional enrichment, falls back to the
|
||||||
// Fall back to basic tx list without details
|
// basic list above on any failure rather than failing the whole call.
|
||||||
return paginatedTxs.map((tx: any) => ({
|
const txHashes = paginatedTxs.map((tx: any) => tx.tx_hash);
|
||||||
txHash: tx.tx_hash,
|
let txInfoResponse: Response;
|
||||||
blockHeight: tx.block_height || 0,
|
try {
|
||||||
blockTime: tx.block_time
|
txInfoResponse = await fetch(`${this.koiosUrl}/api/v1/tx_info`, {
|
||||||
? new Date(tx.block_time * 1000).toISOString()
|
method: 'POST',
|
||||||
: '',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
fee: '0',
|
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) => ({
|
return txInfos.map((info: any) => ({
|
||||||
txHash: info.tx_hash,
|
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> {
|
async submitTx(tx: string): Promise<string> {
|
||||||
// tx is a hex-encoded CBOR transaction
|
// tx is a hex-encoded CBOR transaction
|
||||||
// submit-api expects raw CBOR bytes with Content-Type: application/cbor
|
// submit-api expects raw CBOR bytes with Content-Type: application/cbor
|
||||||
const bytes = Buffer.from(tx, 'hex');
|
const bytes = Buffer.from(tx, 'hex');
|
||||||
|
|
||||||
const response = await fetch(this.submitUrl, {
|
let response: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/cbor' },
|
response = await fetch(this.submitUrl, {
|
||||||
body: bytes,
|
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) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
@@ -614,7 +782,7 @@ export class OgmiosProvider implements Provider {
|
|||||||
response.status,
|
response.status,
|
||||||
response.statusText,
|
response.statusText,
|
||||||
errorText,
|
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