From 0f6131b554ad5936d0f5c5367aeb0a271ca15973 Mon Sep 17 00:00:00 2001 From: "Edward A." Date: Tue, 8 Sep 2026 19:47:08 -0300 Subject: [PATCH] Resolve proving-asset URLs against the page origin Both protocol adapters passed a relative path ('./contract/v1/shielded-night', './contract/v2/shielded-night') to FetchZkConfigProvider. The SDK validates that argument with a bare `new URL(baseURL)` and no base argument (midnight-js-fetch-zk-config-provider 4.1.1 and 5.0.0-beta.7), so the constructor threw "Failed to construct 'URL': Invalid URL" before any indexer or proving work started. On https://shielded-night.pages.dev this made Connect wallet fail on every network; reproduced on 2026-09-08 with a stub 4.x connector on Preprod. Add the dependency-free frontend/protocols/shared/asset-url.ts and resolve the served asset tree from window.location.origin plus import.meta.env.BASE_URL, so a sub-path or absolute Vite base and a non-root SPA route all keep working. ASSET_PATH stays as withCompiledFileAssets metadata, which is never fetched. indexerPublicDataProvider validates its endpoints the same way, so the wallet-supplied indexerUri and indexerWsUri are now checked with requireAbsoluteUrl and fail with a message naming the field and the received value instead of the same opaque TypeError. Served asset paths, contract sources, generated artifacts and frontend/.env are unchanged. --- frontend/README.md | 2 + frontend/protocols/shared/asset-url.ts | 98 +++++++++++++++ frontend/protocols/v1/src/adapter.ts | 16 ++- frontend/protocols/v2/src/adapter.ts | 16 ++- test/unit/frontend-asset-url.unit.test.ts | 144 ++++++++++++++++++++++ 5 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 frontend/protocols/shared/asset-url.ts create mode 100644 test/unit/frontend-asset-url.unit.test.ts diff --git a/frontend/README.md b/frontend/README.md index 9ef6454..3a7d4e2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -53,6 +53,8 @@ The production build contains four distinct runtime assets: v1 and v2 ledger WAS - `/contract/v2/shielded-night` - `/contract/compiled/shielded-night` (legacy v1 URL for already-open clients) +Each adapter resolves its path to an absolute URL from the page origin and the Vite base (`protocols/shared/asset-url.ts`) before handing it to the SDK, because `FetchZkConfigProvider` validates its argument with a bare `new URL()` and rejects a relative path with `Failed to construct 'URL': Invalid URL`. + The sNight token identity is derived inside the selected adapter with that generation's ledger package. ## Conversion and switching behavior diff --git a/frontend/protocols/shared/asset-url.ts b/frontend/protocols/shared/asset-url.ts new file mode 100644 index 0000000..6cf0834 --- /dev/null +++ b/frontend/protocols/shared/asset-url.ts @@ -0,0 +1,98 @@ +/** + * Absolute URLs for the served proving-asset trees, and labelled validation of + * wallet-supplied endpoints. + * + * WHY THIS EXISTS: the Midnight SDK validates the base URL it is given with a + * bare `new URL(baseURL)` — no second (base) argument — inside + * `FetchZkConfigProvider` (`@midnight-ntwrk/midnight-js-fetch-zk-config-provider` + * 4.1.1 and 5.0.0-beta.7) and inside `indexerPublicDataProvider`. A relative + * string such as `'./contract/v1/shielded-night'` therefore throws the browser + * `TypeError: Failed to construct 'URL': Invalid URL` before any network work + * starts. That is exactly what the deployed site did on 2026-09-08: selecting + * Preprod and pressing Connect wallet produced `Connection failed: Failed to + * construct 'URL': Invalid URL`, because the multi-network adapters passed the + * relative path straight to the provider. + * + * The resolution must come from the page ORIGIN plus the Vite base path, never + * from the current page path, so that the SPA opened at a non-root route still + * points at the same served asset tree. + * + * This module is deliberately dependency-free: no `import.meta.env`, no + * `window` access at module level, no imports at all. The root unit tier runs + * it under plain Node, and the callers pass `window.location.origin` and + * `import.meta.env.BASE_URL` in. + */ + +export type AssetProfile = 'v1' | 'v2'; + +export const CONTRACT_ASSET_NAME = 'shielded-night'; + +export interface AssetBaseUrlContext { + /** Absolute origin of the page, e.g. `window.location.origin`. */ + origin: string; + /** Vite base path (`import.meta.env.BASE_URL`); defaults to `'/'`. */ + base?: string; +} + +/** + * Absolute `http(s)` URL of the proving-asset tree for one protocol profile. + * + * `base` may be a path (`'/'`, `'/app'`, `'/app/'`) or a full URL + * (Vite allows an absolute base, e.g. a CDN); a missing trailing slash is + * added so the profile segment is appended rather than replacing the last + * path segment. + */ +export function contractAssetBaseUrl( + profile: AssetProfile, + { origin, base }: AssetBaseUrlContext, +): string { + const rawBase = typeof base === 'string' && base.trim() !== '' ? base.trim() : '/'; + const normalizedBase = rawBase.endsWith('/') ? rawBase : `${rawBase}/`; + const path = `${normalizedBase}contract/${profile}/${CONTRACT_ASSET_NAME}`; + + let resolved: URL; + try { + resolved = new URL(path, origin); + } catch { + throw new Error( + `Cannot resolve the ${profile} proving-asset URL from origin ${JSON.stringify(origin)} ` + + `and base ${JSON.stringify(rawBase)}; expected an absolute http or https origin.`, + ); + } + if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') { + throw new Error( + `The ${profile} proving-asset URL resolved to ${JSON.stringify(resolved.href)}, ` + + `which is not an http or https URL; the proving-asset provider requires one.`, + ); + } + return resolved.href; +} + +/** + * Returns `value` trimmed when it is an absolute URL with one of `protocols`. + * + * Throws a descriptive `Error` naming the field and the received value + * otherwise — never the SDK's bare `TypeError: Failed to construct 'URL': + * Invalid URL`, which gives the user nothing to act on. + */ +export function requireAbsoluteUrl( + value: unknown, + label: string, + protocols: readonly string[], +): string { + const invalid = () => new Error( + `The wallet returned an invalid ${label} (${JSON.stringify(value)}); ` + + `expected an absolute ${protocols.join(' or ')} URL.`, + ); + if (typeof value !== 'string') throw invalid(); + const trimmed = value.trim(); + if (trimmed === '') throw invalid(); + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw invalid(); + } + if (!protocols.includes(parsed.protocol)) throw invalid(); + return trimmed; +} diff --git a/frontend/protocols/v1/src/adapter.ts b/frontend/protocols/v1/src/adapter.ts index ec1e1aa..0c4d63d 100644 --- a/frontend/protocols/v1/src/adapter.ts +++ b/frontend/protocols/v1/src/adapter.ts @@ -10,8 +10,11 @@ import { createProofProvider } from '@midnight-ntwrk/midnight-js-types'; import { MidnightBech32m } from '@midnight-ntwrk/wallet-sdk-address-format'; import * as ShieldedNight from '../../../../src/managed/contract/index.js'; import { createProtocolSession, createWalletBoundary } from '../../shared/adapter-core'; +import { contractAssetBaseUrl, requireAbsoluteUrl } from '../../shared/asset-url'; import type { ProfileBridge } from '../../shared/types'; +// Metadata only: `withCompiledFileAssets` stores this path on the compiled +// contract and never fetches with it. The provider below needs an absolute URL. const ASSET_PATH = './contract/v1/shielded-night'; const compiled = (CompiledContract.make as unknown as (name: string, contract: unknown) => any)( 'ShieldedNight-v1', @@ -51,8 +54,17 @@ const bridge: ProfileBridge = { deriveWrapperColor, async buildProviders(input) { const configuration = await input.connectedAPI.getConfiguration(); - const zkConfigProvider = new FetchZkConfigProvider(ASSET_PATH, window.fetch.bind(window)); - const publicDataProvider = indexerPublicDataProvider(configuration.indexerUri, configuration.indexerWsUri); + // The SDK validates this argument with a bare `new URL(baseURL)`, so it must + // be absolute; a relative path throws "Failed to construct 'URL': Invalid URL". + const zkConfigProvider = new FetchZkConfigProvider( + contractAssetBaseUrl('v1', { origin: window.location.origin, base: import.meta.env.BASE_URL }), + window.fetch.bind(window), + ); + // Same bare `new URL()` check inside the indexer provider: name the offending + // field and value instead of surfacing the opaque TypeError. + const indexerUri = requireAbsoluteUrl(configuration.indexerUri, 'indexer URL', ['http:', 'https:']); + const indexerWsUri = requireAbsoluteUrl(configuration.indexerWsUri, 'indexer WebSocket URL', ['ws:', 'wss:']); + const publicDataProvider = indexerPublicDataProvider(indexerUri, indexerWsUri); const originalQuery = publicDataProvider.queryZSwapAndContractState.bind(publicDataProvider); publicDataProvider.queryZSwapAndContractState = async (...args: Parameters) => { const result = await originalQuery(...args); diff --git a/frontend/protocols/v2/src/adapter.ts b/frontend/protocols/v2/src/adapter.ts index 065c4f5..5bac173 100644 --- a/frontend/protocols/v2/src/adapter.ts +++ b/frontend/protocols/v2/src/adapter.ts @@ -10,8 +10,11 @@ import * as ledgerV2 from '@midnightntwrk/ledger-v9'; import { MidnightBech32m } from '@midnightntwrk/wallet-sdk-address-format'; import * as ShieldedNight from '../../../../contracts/v2/managed/contract/index.js'; import { createProtocolSession, createWalletBoundary } from '../../shared/adapter-core'; +import { contractAssetBaseUrl, requireAbsoluteUrl } from '../../shared/asset-url'; import type { ProfileBridge } from '../../shared/types'; +// Metadata only: `withCompiledFileAssets` stores this path on the compiled +// contract and never fetches with it. The provider below needs an absolute URL. const ASSET_PATH = './contract/v2/shielded-night'; const compiled = (CompiledContract.make as unknown as (name: string, contract: unknown) => any)( 'ShieldedNight-v2', @@ -51,10 +54,17 @@ const bridge: ProfileBridge = { deriveWrapperColor, async buildProviders(input) { const configuration = await input.connectedAPI.getConfiguration(); - const zkConfigProvider = new FetchZkConfigProvider(ASSET_PATH, { fetchFunc: window.fetch.bind(window) }); + // The SDK validates this argument with a bare `new URL(baseURL)`, so it must + // be absolute; a relative path throws "Failed to construct 'URL': Invalid URL". + const zkConfigProvider = new FetchZkConfigProvider( + contractAssetBaseUrl('v2', { origin: window.location.origin, base: import.meta.env.BASE_URL }), + { fetchFunc: window.fetch.bind(window) }, + ); + // Same bare `new URL()` check inside the indexer provider: name the offending + // field and value instead of surfacing the opaque TypeError. const publicDataProvider = indexerPublicDataProvider({ - queryURL: configuration.indexerUri, - subscriptionURL: configuration.indexerWsUri, + queryURL: requireAbsoluteUrl(configuration.indexerUri, 'indexer URL', ['http:', 'https:']), + subscriptionURL: requireAbsoluteUrl(configuration.indexerWsUri, 'indexer WebSocket URL', ['ws:', 'wss:']), }); const originalQuery = publicDataProvider.queryZSwapAndContractState.bind(publicDataProvider); publicDataProvider.queryZSwapAndContractState = async (...args: Parameters) => { diff --git a/test/unit/frontend-asset-url.unit.test.ts b/test/unit/frontend-asset-url.unit.test.ts new file mode 100644 index 0000000..f27096f --- /dev/null +++ b/test/unit/frontend-asset-url.unit.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { + CONTRACT_ASSET_NAME, + contractAssetBaseUrl, + requireAbsoluteUrl, +} from '../../frontend/protocols/shared/asset-url.js'; + +const PAGES_ORIGIN = 'https://shielded-night.pages.dev'; + +describe('contractAssetBaseUrl', () => { + it('resolves each protocol profile against the page origin', () => { + expect(contractAssetBaseUrl('v1', { origin: PAGES_ORIGIN })) + .toBe(`${PAGES_ORIGIN}/contract/v1/shielded-night`); + expect(contractAssetBaseUrl('v2', { origin: PAGES_ORIGIN })) + .toBe(`${PAGES_ORIGIN}/contract/v2/shielded-night`); + }); + + it('exports the served asset name used by both profiles', () => { + expect(CONTRACT_ASSET_NAME).toBe('shielded-night'); + expect(contractAssetBaseUrl('v1', { origin: PAGES_ORIGIN })) + .toContain(`/${CONTRACT_ASSET_NAME}`); + }); + + it('treats a missing base and an explicit root base identically', () => { + const implicit = contractAssetBaseUrl('v1', { origin: PAGES_ORIGIN }); + expect(contractAssetBaseUrl('v1', { origin: PAGES_ORIGIN, base: '/' })).toBe(implicit); + expect(contractAssetBaseUrl('v1', { origin: PAGES_ORIGIN, base: undefined })).toBe(implicit); + }); + + it('honours a sub-path Vite base with or without a trailing slash', () => { + expect(contractAssetBaseUrl('v1', { origin: PAGES_ORIGIN, base: '/app/' })) + .toBe(`${PAGES_ORIGIN}/app/contract/v1/shielded-night`); + expect(contractAssetBaseUrl('v1', { origin: PAGES_ORIGIN, base: '/app' })) + .toBe(`${PAGES_ORIGIN}/app/contract/v1/shielded-night`); + expect(contractAssetBaseUrl('v2', { origin: PAGES_ORIGIN, base: '/app' })) + .toBe(`${PAGES_ORIGIN}/app/contract/v2/shielded-night`); + }); + + it('honours an absolute base (Vite allows a full URL, e.g. a CDN)', () => { + expect(contractAssetBaseUrl('v1', { origin: PAGES_ORIGIN, base: 'https://cdn.example/site/' })) + .toBe('https://cdn.example/site/contract/v1/shielded-night'); + expect(contractAssetBaseUrl('v2', { origin: PAGES_ORIGIN, base: 'https://cdn.example/site' })) + .toBe('https://cdn.example/site/contract/v2/shielded-night'); + }); + + it('resolves from the origin, never from the current page path', () => { + // The SPA can be opened at a non-root route; the asset tree is still served + // from the origin (plus base), so the deep route must not leak into the URL. + const url = new URL('/swap/preprod/details?x=1#frag', PAGES_ORIGIN); + expect(contractAssetBaseUrl('v1', { origin: url.origin })) + .toBe(`${PAGES_ORIGIN}/contract/v1/shielded-night`); + }); + + it('produces a URL that survives the SDK check on http and https origins', () => { + // FetchZkConfigProvider does `new URL(baseURL)` and then rejects anything + // that is not http:/https:. Both forms must pass that check unchanged. + for (const origin of ['http://127.0.0.1:12345', PAGES_ORIGIN]) { + const resolved = contractAssetBaseUrl('v1', { origin }); + const parsed = new URL(resolved); + expect(['http:', 'https:']).toContain(parsed.protocol); + expect(parsed.href).toBe(resolved); + } + expect(contractAssetBaseUrl('v2', { origin: 'http://127.0.0.1:12345' })) + .toBe('http://127.0.0.1:12345/contract/v2/shielded-night'); + }); + + it('documents the deployed regression: the old relative path is not a URL', () => { + // This is the exact 2026-09-08 production failure — the adapters passed + // './contract/v1/shielded-night' straight to FetchZkConfigProvider, whose + // `new URL(baseURL)` (no base argument) throws "Invalid URL". + expect(() => new URL('./contract/v1/shielded-night')).toThrow(); + expect(() => new URL('./contract/v2/shielded-night')).toThrow(); + }); + + it('rejects a non-http(s) origin with a descriptive error', () => { + expect(() => contractAssetBaseUrl('v1', { origin: 'file:///x' })) + .toThrow(/not an http or https URL/); + expect(() => contractAssetBaseUrl('v1', { origin: 'file:///x' })).toThrow(Error); + }); + + it('rejects an unusable origin with a descriptive error', () => { + expect(() => contractAssetBaseUrl('v1', { origin: 'not a url' })) + .toThrow(/Cannot resolve the v1 proving-asset URL/); + }); +}); + +describe('requireAbsoluteUrl', () => { + const HTTP = ['http:', 'https:'] as const; + const WS = ['ws:', 'wss:'] as const; + + it('accepts the real public indexer endpoints', () => { + const endpoints: ReadonlyArray = [ + ['https://indexer.preprod.midnight.network/api/v4/graphql', 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws'], + ['https://indexer.preview.midnight.network/api/v4/graphql', 'wss://indexer.preview.midnight.network/api/v4/graphql/ws'], + ['https://indexer.stagenet.shielded.tools/api/v4/graphql', 'wss://indexer.stagenet.shielded.tools/api/v4/graphql/ws'], + ]; + for (const [query, subscription] of endpoints) { + expect(requireAbsoluteUrl(query, 'indexer URL', HTTP)).toBe(query); + expect(requireAbsoluteUrl(subscription, 'indexer WebSocket URL', WS)).toBe(subscription); + } + }); + + it('accepts plain http and ws endpoints and trims surrounding whitespace', () => { + expect(requireAbsoluteUrl('http://127.0.0.1:12345/api/v4/graphql', 'indexer URL', HTTP)) + .toBe('http://127.0.0.1:12345/api/v4/graphql'); + expect(requireAbsoluteUrl(' ws://127.0.0.1:12345/api/v4/graphql/ws ', 'indexer WebSocket URL', WS)) + .toBe('ws://127.0.0.1:12345/api/v4/graphql/ws'); + }); + + it('rejects blank, missing, relative and wrong-scheme values with a labelled Error', () => { + const rejected: readonly unknown[] = [undefined, null, '', ' ', '/api/v4/graphql', 'ftp://x', 42]; + for (const value of rejected) { + let caught: unknown; + try { + requireAbsoluteUrl(value, 'indexer URL', HTTP); + } catch (error) { + caught = error; + } + expect(caught, `expected ${JSON.stringify(value)} to be rejected`).toBeInstanceOf(Error); + // Not the SDK's bare "Failed to construct 'URL': Invalid URL" TypeError. + expect(caught).not.toBeInstanceOf(TypeError); + const message = (caught as Error).message; + expect(message).toContain('indexer URL'); + // `JSON.stringify(undefined)` is `undefined`; the template literal in the + // module renders it as the text "undefined", which is what the user sees. + expect(message).toContain(String(JSON.stringify(value))); + expect(message).toContain('http: or https:'); + expect(message).not.toContain('Invalid URL'); + } + }); + + it('rejects a websocket value where an http endpoint is required, and vice versa', () => { + expect(() => requireAbsoluteUrl('wss://indexer.preprod.midnight.network/api/v4/graphql/ws', 'indexer URL', HTTP)) + .toThrow(/invalid indexer URL \("wss:\/\/indexer\.preprod\.midnight\.network\/api\/v4\/graphql\/ws"\)/); + expect(() => requireAbsoluteUrl('https://indexer.preprod.midnight.network/api/v4/graphql', 'indexer WebSocket URL', WS)) + .toThrow(/invalid indexer WebSocket URL/); + }); + + it('names the WebSocket field when the wallet returns a blank subscription URL', () => { + // Spec US3: a blank indexerWsUri must produce an actionable message. + expect(() => requireAbsoluteUrl('', 'indexer WebSocket URL', WS)) + .toThrow('The wallet returned an invalid indexer WebSocket URL (""); expected an absolute ws: or wss: URL.'); + }); +});