Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions frontend/protocols/shared/asset-url.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 14 additions & 2 deletions frontend/protocols/v1/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<typeof originalQuery>) => {
const result = await originalQuery(...args);
Expand Down
16 changes: 13 additions & 3 deletions frontend/protocols/v2/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<typeof originalQuery>) => {
Expand Down
144 changes: 144 additions & 0 deletions test/unit/frontend-asset-url.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<readonly [string, string]> = [
['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.');
});
});
Loading