diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 8a74a86..b11bab8 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -61,6 +61,7 @@ import { } from "./errors.js"; import { normalizeRoundId, normalizeSorobanContractId } from "./ids.js"; import { + assertEndpointTransportSecurity, validateContractNetwork, validatePasskeySession, type PasskeySessionBinding, @@ -213,11 +214,11 @@ export class SubRosaClient { constructor(config: SubRosaClientConfig) { const allowHttp = config.allowHttp ?? false; - if (/^http:\/\//i.test(config.rpcUrl) && !allowHttp) { - throw new SubRosaClientConfigError( - "rpcUrl must use https unless allowHttp is explicitly enabled", - ); - } + assertEndpointTransportSecurity(config.rpcUrl, { + networkPassphrase: config.networkPassphrase, + allowHttp, + label: "rpcUrl", + }); const confirmTimeout = config.confirmTimeout ?? 60_000; if (!Number.isFinite(confirmTimeout) || confirmTimeout < 1_000) { diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 3b44f34..b7fb374 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -4,6 +4,152 @@ import type { EscrowConservationReport, } from "./conservation.js"; +export type RoundContractErrorCode = + | "NotInitialized" + | "AlreadyInitialized" + | "RoundNotFound" + | "BidNotFound" + | "CommitClosed" + | "CommitNotClosed" + | "CommitDeadlineAfterReveal" + | "RevealNotOpen" + | "RevealAlreadyOpen" + | "RevealWindowClosed" + | "RevealStillOpen" + | "NotCleared" + | "AlreadyCleared" + | "AlreadySettled" + | "RoundVoided" + | "NotVoidable" + | "WrongStatus" + | "InvalidDrandSignature" + | "HashMismatch" + | "AlreadyRevealed" + | "PayloadTooLarge" + | "InvalidAmount" + | "BidExceedsEscrow" + | "DeadlineInPast" + | "NoValidBids" + | "RoundFull" + | "InvalidLimit" + | "EscrowNotConserved"; + +export interface RoundContractErrorSpec { + readonly code: number; + readonly name: RoundContractErrorCode; + readonly retryable: boolean; +} + +export interface ContractErrorEntry { + readonly code: number; + readonly name: string; +} + +/** + * Complete, single-source mapping of Soroban round contract errors to stable SDK codes and retryable flags. + * Derived from contracts/round/src/error_paths.rs, contracts/round/src/types.rs, and ERRORS.md. + */ +export const ROUND_CONTRACT_ERRORS: Readonly> = Object.freeze({ + 1: { code: 1, name: "NotInitialized", retryable: false }, + 2: { code: 2, name: "AlreadyInitialized", retryable: false }, + 3: { code: 3, name: "RoundNotFound", retryable: false }, + 4: { code: 4, name: "BidNotFound", retryable: false }, + 10: { code: 10, name: "CommitClosed", retryable: false }, + 11: { code: 11, name: "CommitNotClosed", retryable: true }, + 12: { code: 12, name: "CommitDeadlineAfterReveal", retryable: false }, + 13: { code: 13, name: "RevealNotOpen", retryable: true }, + 14: { code: 14, name: "RevealAlreadyOpen", retryable: false }, + 15: { code: 15, name: "RevealWindowClosed", retryable: false }, + 16: { code: 16, name: "RevealStillOpen", retryable: true }, + 17: { code: 17, name: "NotCleared", retryable: true }, + 18: { code: 18, name: "AlreadyCleared", retryable: false }, + 19: { code: 19, name: "AlreadySettled", retryable: false }, + 20: { code: 20, name: "RoundVoided", retryable: false }, + 21: { code: 21, name: "NotVoidable", retryable: true }, + 22: { code: 22, name: "WrongStatus", retryable: false }, + 30: { code: 30, name: "InvalidDrandSignature", retryable: false }, + 31: { code: 31, name: "HashMismatch", retryable: false }, + 32: { code: 32, name: "AlreadyRevealed", retryable: false }, + 33: { code: 33, name: "PayloadTooLarge", retryable: false }, + 34: { code: 34, name: "InvalidAmount", retryable: false }, + 35: { code: 35, name: "BidExceedsEscrow", retryable: false }, + 36: { code: 36, name: "DeadlineInPast", retryable: false }, + 37: { code: 37, name: "NoValidBids", retryable: false }, + 38: { code: 38, name: "RoundFull", retryable: false }, + 39: { code: 39, name: "InvalidLimit", retryable: false }, + 40: { code: 40, name: "EscrowNotConserved", retryable: false }, +}); + +export const ROUND_CONTRACT_ERRORS_BY_NAME: Readonly> = Object.freeze( + Object.fromEntries( + Object.values(ROUND_CONTRACT_ERRORS).map((spec) => [spec.name, spec]), + ), +); + +/** + * Look up a contract error specification by numeric code or variant name. + */ +export function getRoundContractError( + codeOrName: number | string | undefined | null, +): RoundContractErrorSpec | undefined { + if (codeOrName === undefined || codeOrName === null) return undefined; + if (typeof codeOrName === "number") { + return ROUND_CONTRACT_ERRORS[codeOrName]; + } + const numeric = Number(codeOrName); + if (!Number.isNaN(numeric) && Object.prototype.hasOwnProperty.call(ROUND_CONTRACT_ERRORS, numeric)) { + return ROUND_CONTRACT_ERRORS[numeric]; + } + return ROUND_CONTRACT_ERRORS_BY_NAME[codeOrName]; +} + +/** + * Determine whether a contract error (by numeric code or variant name) is retryable. + * Unknown / unmapped errors always evaluate to false (non-retryable). + */ +export function isRoundContractErrorRetryable( + codeOrName: number | string | undefined | null, +): boolean { + const spec = getRoundContractError(codeOrName); + return spec !== undefined ? spec.retryable : false; +} + +/** + * Compare an external list of contract error items (e.g. from error_paths.rs, types.rs, or ERRORS.md) + * against the SDK mapping. Returns a list of drift failure messages, or an empty array when in sync. + */ +export function diffContractErrorMapping( + contractErrors: readonly ContractErrorEntry[], +): string[] { + const sdkByCode = new Map(Object.values(ROUND_CONTRACT_ERRORS).map((e) => [e.code, e])); + const inputByCode = new Map(contractErrors.map((e) => [e.code, e])); + + const failures: string[] = []; + + for (const item of contractErrors) { + const mapped = sdkByCode.get(item.code); + if (!mapped) { + failures.push( + `Contract error ${item.name} (#${item.code}) is not mapped in SDK ROUND_CONTRACT_ERRORS`, + ); + } else if (mapped.name !== item.name) { + failures.push( + `Contract error code #${item.code} maps to '${mapped.name}' in SDK but '${item.name}' in contract source`, + ); + } + } + + for (const mapped of Object.values(ROUND_CONTRACT_ERRORS)) { + if (!inputByCode.has(mapped.code)) { + failures.push( + `SDK maps error ${mapped.name} (#${mapped.code}) which is missing from contract errors list`, + ); + } + } + + return failures; +} + export class SubRosaClientConfigError extends Error { readonly name = "SubRosaClientConfigError"; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0d7d15c..dcae6ca 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -71,9 +71,17 @@ export { type ProveEscrowConservationOptions, } from "./conservation.js"; export { + assertEndpointTransportSecurity, + describeNetworkPassphraseMismatch, + isLoopbackHost, + isMainnetPassphrase, + networkDisplayName, + networkPassphrasesMatch, + SubRosaNetworkPassphraseMismatchError, validateContractNetwork, validatePasskeySession, type ContractNetworkValidationConfig, + type EndpointTransportSecurityOptions, type NetworkValidationServer, type PasskeySessionBinding, type SessionBindingTarget, diff --git a/packages/sdk/src/mainnet-readiness.test.ts b/packages/sdk/src/mainnet-readiness.test.ts index c6c2e1e..0d62636 100644 --- a/packages/sdk/src/mainnet-readiness.test.ts +++ b/packages/sdk/src/mainnet-readiness.test.ts @@ -607,6 +607,19 @@ describe("runMainnetReadiness — live comparison", () => { ); }); + it("blocks when rpcUrl uses http:// instead of https://", async () => { + const report = await runMainnetReadiness( + defaultMainnetReadinessInput({ + live: false, + rpcUrl: "http://rpc.mainnet.example", + }), + ); + const rpcCheck = report.checks.find((c) => c.id === "rpc-url"); + assert.equal(rpcCheck?.status, "block"); + assert.match(rpcCheck?.message ?? "", /must use HTTPS/); + assert.equal(hasBlockingFailures(report.checks), true); + }); + it("blocks every deployment field when no read-only client is available", async () => { const report = await runMainnetReadiness(defaultMainnetReadinessInput(), { rpc: mockRpc("500000000"), diff --git a/packages/sdk/src/mainnet-readiness.ts b/packages/sdk/src/mainnet-readiness.ts index e02a57a..41dbb88 100644 --- a/packages/sdk/src/mainnet-readiness.ts +++ b/packages/sdk/src/mainnet-readiness.ts @@ -822,8 +822,8 @@ function rpcUrlCheck(rpcUrl: string): ReadinessCheck { return check( "rpc-url", "RPC URL", - "warn", - "uses HTTP — prefer HTTPS for mainnet", + "block", + "uses HTTP — mainnet RPC endpoints must use HTTPS", ); } return check( diff --git a/packages/sdk/src/network.test.ts b/packages/sdk/src/network.test.ts index 1edb817..ca58f51 100644 --- a/packages/sdk/src/network.test.ts +++ b/packages/sdk/src/network.test.ts @@ -2,10 +2,15 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { StrKey } from "@stellar/stellar-sdk"; import { + SubRosaClientConfigError, SubRosaNetworkMismatchError, SubRosaSessionMismatchError, } from "./errors.js"; +import { SubRosaClient } from "./client.js"; import { + assertEndpointTransportSecurity, + isLoopbackHost, + isMainnetPassphrase, validateContractNetwork, validatePasskeySession, type NetworkValidationServer, @@ -245,3 +250,73 @@ describe("validatePasskeySession", () => { }); }); +describe("assertEndpointTransportSecurity", () => { + it("identifies mainnet passphrases and loopback hosts", () => { + assert.equal(isMainnetPassphrase(PUBLIC), true); + assert.equal(isMainnetPassphrase(TESTNET), false); + assert.equal(isLoopbackHost("localhost"), true); + assert.equal(isLoopbackHost("127.0.0.1"), true); + assert.equal(isLoopbackHost("[::1]"), true); + assert.equal(isLoopbackHost("rpc.example.com"), false); + }); + + it("requires https:// on mainnet even when allowHttp is true or host is localhost", () => { + assert.doesNotThrow(() => + assertEndpointTransportSecurity("https://rpc.mainnet.example", { + networkPassphrase: PUBLIC, + }), + ); + + for (const url of [ + "http://rpc.mainnet.example", + "http://localhost:8000", + ]) { + assert.throws( + () => + assertEndpointTransportSecurity(url, { + networkPassphrase: PUBLIC, + allowHttp: true, + }), + (err: unknown) => { + assert.ok(err instanceof SubRosaClientConfigError); + assert.match(err.message, /must use https:\/\/ when configured for mainnet/); + return true; + }, + ); + assert.throws( + () => + new SubRosaClient({ + rpcUrl: url, + networkPassphrase: PUBLIC, + contractId: CONTRACT_ID, + allowHttp: true, + }), + SubRosaClientConfigError, + ); + } + }); + + it("allows http:// on testnet only when allowHttp is explicitly enabled", () => { + assert.throws( + () => + new SubRosaClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: TESTNET, + contractId: CONTRACT_ID, + }), + SubRosaClientConfigError, + ); + + assert.doesNotThrow( + () => + new SubRosaClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: TESTNET, + contractId: CONTRACT_ID, + allowHttp: true, + }), + ); + }); +}); + + diff --git a/packages/sdk/src/network.ts b/packages/sdk/src/network.ts index 2cbdefd..94ab885 100644 --- a/packages/sdk/src/network.ts +++ b/packages/sdk/src/network.ts @@ -1,10 +1,83 @@ import { Contract, rpc } from "@stellar/stellar-sdk"; import { + SubRosaClientConfigError, SubRosaNetworkMismatchError, SubRosaSessionMismatchError, } from "./errors.js"; import { normalizeSorobanContractId } from "./ids.js"; +/** True when `passphrase` identifies Stellar Public (Mainnet). */ +export function isMainnetPassphrase(passphrase: string): boolean { + return networkDisplayName(passphrase) === "Public"; +} + +/** True when `hostname` is a loopback address (`localhost`, `127.0.0.1`, `[::1]`). */ +export function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "[::1]" || + normalized === "::1" + ); +} + +export interface EndpointTransportSecurityOptions { + /** Stellar network passphrase. Mainnet passphrases always require `https://`. */ + networkPassphrase?: string; + /** Explicit opt-in for `http://` on non-mainnet development/test networks. */ + allowHttp?: boolean; + /** Field or env-var label used in error messages. Default: `"rpcUrl"`. */ + label?: string; +} + +/** + * Enforce transport security on an RPC or service endpoint URL: + * - Mainnet (`Public Global Stellar Network ; September 2015`) ALWAYS requires `https://`, + * even if `allowHttp` is set or the host is loopback. + * - Non-mainnet networks require `https://` unless `allowHttp: true` is explicitly set. + */ +export function assertEndpointTransportSecurity( + rawUrl: string, + options: EndpointTransportSecurityOptions = {}, +): URL { + const label = options.label ?? "rpcUrl"; + let parsed: URL; + try { + parsed = new URL(rawUrl.trim()); + } catch (cause) { + throw new SubRosaClientConfigError( + `${label} must be a valid HTTP(S) URL`, + { cause }, + ); + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new SubRosaClientConfigError( + `${label} must use https:// or http:// (got ${parsed.protocol})`, + ); + } + + if (parsed.protocol === "http:") { + if ( + options.networkPassphrase !== undefined && + isMainnetPassphrase(options.networkPassphrase) + ) { + throw new SubRosaClientConfigError( + `${label} must use https:// when configured for mainnet`, + ); + } + if (!options.allowHttp) { + throw new SubRosaClientConfigError( + `${label} must use https unless allowHttp is explicitly enabled`, + ); + } + } + + return parsed; +} + + /** * Human-readable label for a Stellar network passphrase. Unknown/custom * networks fall back to the passphrase itself so the caller still sees which diff --git a/packages/sdk/src/public-api-snapshot.test.ts b/packages/sdk/src/public-api-snapshot.test.ts index 2c7d558..c7238c5 100644 --- a/packages/sdk/src/public-api-snapshot.test.ts +++ b/packages/sdk/src/public-api-snapshot.test.ts @@ -4,7 +4,6 @@ import assert from "node:assert/strict"; import * as sdk from "./index.js"; const EXPECTED_EXPORTS = [ - "SubRosaPaginationError", "ASSET_FIXTURES", "AssetConfigError", "DEPLOYMENT_CHECK_IDS", @@ -49,6 +48,7 @@ const EXPECTED_EXPORTS = [ "SubRosaTransactionError", "TERMINAL_ROUND_STATUSES", "assertDeploymentMatches", + "assertEndpointTransportSecurity", "assertMainnetConfirmed", "assertMicroAmounts", "assertReadinessForExecute", @@ -76,6 +76,8 @@ const EXPECTED_EXPORTS = [ "isKeeperRoundActive", "isKeeperRoundSettlementPending", "isKeeperRoundTerminal", + "isLoopbackHost", + "isMainnetPassphrase", "isRoundContractErrorRetryable", "isTerminalRoundStatus", "nativeXlmSacId", @@ -86,7 +88,6 @@ const EXPECTED_EXPORTS = [ "networkPassphrasesMatch", "normalizeRoundId", "normalizeSorobanContractId", - "parseMicroStroops", "parseReceipt", "proveEscrowConservationFromPages", "redactReceipt", diff --git a/packages/tlock/src/index.ts b/packages/tlock/src/index.ts index 5769024..a414c1c 100644 --- a/packages/tlock/src/index.ts +++ b/packages/tlock/src/index.ts @@ -53,12 +53,15 @@ export { roundInSeconds, fetchRoundBeacon, fetchRoundSignature, + DEFAULT_QUICKNET_URL, QUICKNET_HASH, QUICKNET_GENESIS, QUICKNET_PERIOD, QUICKNET_FIXTURE, + assertDrandEndpointUrl, assertQuicknetFixture, type DrandClient, + type QuicknetClientOptions, } from "./quicknet.js"; export { drandSignatureToSoroban, encodeG1Soroban } from "./bls.js"; diff --git a/packages/tlock/src/quicknet.test.ts b/packages/tlock/src/quicknet.test.ts index dc21adb..5aefd1b 100644 --- a/packages/tlock/src/quicknet.test.ts +++ b/packages/tlock/src/quicknet.test.ts @@ -2,7 +2,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { QUICKNET_HASH, assertQuicknetFixture } from "./quicknet.js"; +import { + DEFAULT_QUICKNET_URL, + QUICKNET_HASH, + assertDrandEndpointUrl, + assertQuicknetFixture, + quicknet, +} from "./quicknet.js"; import { classifyDrandRound } from "./freshness.js"; import fs from "node:fs"; @@ -92,3 +98,37 @@ test("a mutated period fails the fixture comparison", () => { const mutated = { ...QUICKNET_FIXTURE, period: 4 }; assert.throws(() => assertQuicknetFixture(mutated), /period mismatch/); }); + +test("assertDrandEndpointUrl requires https:// for remote Drand endpoints and explicit opt-in for localhost http://", () => { + assert.equal( + assertDrandEndpointUrl(DEFAULT_QUICKNET_URL), + "https://api.drand.sh", + ); + assert.equal( + assertDrandEndpointUrl("https://api.drand.sh/"), + "https://api.drand.sh", + ); + + assert.throws( + () => assertDrandEndpointUrl("http://api.drand.sh"), + /must use https:\/\//, + ); + assert.throws( + () => assertDrandEndpointUrl("http://api.drand.sh", { allowLocalHttp: true }), + /must use https:\/\//, + ); + assert.throws( + () => assertDrandEndpointUrl("http://localhost:8080"), + /must use https:\/\//, + ); + assert.equal( + assertDrandEndpointUrl("http://localhost:8080/", { allowLocalHttp: true }), + "http://localhost:8080", + ); + + assert.throws( + () => quicknet({ url: "http://api.drand.sh" }), + /must use https:\/\//, + ); +}); + diff --git a/packages/tlock/src/quicknet.ts b/packages/tlock/src/quicknet.ts index a11be6e..8b3a7be 100644 --- a/packages/tlock/src/quicknet.ts +++ b/packages/tlock/src/quicknet.ts @@ -49,7 +49,65 @@ export function assertQuicknetFixture(info: { export type DrandClient = ReturnType; -export function quicknet(): DrandClient { +export const DEFAULT_QUICKNET_URL = "https://api.drand.sh"; + +export interface QuicknetClientOptions { + /** Drand HTTP(S) endpoint URL. Defaults to `DEFAULT_QUICKNET_URL` (`https://api.drand.sh`). */ + url?: string; + /** Explicit opt-in for loopback `http://localhost` / `http://127.0.0.1` endpoints in local development. */ + allowLocalHttp?: boolean; +} + +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "[::1]" || + normalized === "::1" + ); +} + +/** + * Validate a Drand endpoint URL, requiring `https://` for all remote endpoints + * and only permitting loopback `http://` when `allowLocalHttp: true` is passed. + */ +export function assertDrandEndpointUrl( + rawUrl: string, + options: { allowLocalHttp?: boolean } = {}, +): string { + let parsed: URL; + try { + parsed = new URL(rawUrl.trim()); + } catch (cause) { + throw new Error( + `Drand endpoint must be a valid URL, got ${JSON.stringify(rawUrl)}`, + { cause }, + ); + } + + if (parsed.protocol === "https:") { + return parsed.toString().replace(/\/$/, ""); + } + + if (parsed.protocol === "http:") { + if (options.allowLocalHttp && isLoopbackHost(parsed.hostname)) { + return parsed.toString().replace(/\/$/, ""); + } + throw new Error( + "Drand endpoint must use https:// (remote or unopted-in http:// endpoints are rejected)", + ); + } + + throw new Error( + `Drand endpoint must use https://, got protocol ${parsed.protocol}`, + ); +} + +export function quicknet(options: QuicknetClientOptions = {}): DrandClient { + assertDrandEndpointUrl(options.url ?? DEFAULT_QUICKNET_URL, { + allowLocalHttp: options.allowLocalHttp, + }); return quicknetClient(); } diff --git a/services/appraisal-api/README.md b/services/appraisal-api/README.md index 0385a6b..4fc8a82 100644 --- a/services/appraisal-api/README.md +++ b/services/appraisal-api/README.md @@ -112,6 +112,8 @@ rejected because they would otherwise propagate invalidly into the math. ## Quick start +> **Transport security (`RPC_URL`)**: `RPC_URL` must use `https://` whenever `X402_NETWORK=stellar:pubnet` and for all remote RPC hosts. Unencrypted `http://` is permitted only for local loopback development (`localhost`, `127.0.0.1`, `[::1]`) when running on `stellar:testnet`. + ```bash pnpm appraisal:start # boots the server on :4021 pnpm appraisal:test # unit + fixture tests (no external calls) diff --git a/services/appraisal-api/src/config.test.ts b/services/appraisal-api/src/config.test.ts index fdefa5c..ed038d0 100644 --- a/services/appraisal-api/src/config.test.ts +++ b/services/appraisal-api/src/config.test.ts @@ -227,4 +227,40 @@ describe("RPC URL embedded credentials", () => { assert.equal(configFromEnv({ ...MINIMAL_ENV, RPC_URL }).rpcUrl, RPC_URL); }); } + + test("rejects http:// RPC_URL in stellar:pubnet mode", () => { + assertConfigError( + { + ...MINIMAL_ENV, + X402_NETWORK: STELLAR_PUBNET_CAIP2, + RPC_URL: "http://rpc.pubnet.example", + }, + "RPC_URL", + ); + assertConfigError( + { + ...MINIMAL_ENV, + X402_NETWORK: STELLAR_PUBNET_CAIP2, + RPC_URL: "http://localhost:8000", + }, + "RPC_URL", + ); + }); + + test("rejects non-loopback http:// RPC_URL on testnet while keeping localhost explicit", () => { + assertConfigError( + { + ...MINIMAL_ENV, + RPC_URL: "http://rpc.testnet.example:8000", + }, + "RPC_URL", + ); + assert.equal( + configFromEnv({ + ...MINIMAL_ENV, + RPC_URL: "http://127.0.0.1:8000", + }).rpcUrl, + "http://127.0.0.1:8000", + ); + }); }); diff --git a/services/appraisal-api/src/config.ts b/services/appraisal-api/src/config.ts index 693d642..3a2a94a 100644 --- a/services/appraisal-api/src/config.ts +++ b/services/appraisal-api/src/config.ts @@ -102,6 +102,16 @@ function parsePort(value: string | undefined): number { return port; } +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "[::1]" || + normalized === "::1" + ); +} + function parseRpcUrl( value: string | undefined, network: AppraisalNetwork, @@ -137,6 +147,21 @@ function parseRpcUrl( ); } + if (url.protocol === "http:") { + if (network === STELLAR_PUBNET_CAIP2) { + throw new AppraisalConfigError( + "RPC_URL", + "must use https:// when X402_NETWORK=stellar:pubnet", + ); + } + if (!isLoopbackHost(url.hostname)) { + throw new AppraisalConfigError( + "RPC_URL", + "http:// is only allowed for localhost/loopback endpoints on testnet; use https:// for remote RPC endpoints", + ); + } + } + return url.toString().replace(/\/$/, ""); }