diff --git a/apps/web/src/lib/config.test.ts b/apps/web/src/lib/config.test.ts index 5fc996c..e4339a4 100644 --- a/apps/web/src/lib/config.test.ts +++ b/apps/web/src/lib/config.test.ts @@ -254,3 +254,22 @@ test("swapped VITE_PASSKEY_NETWORK_PASSPHRASE is reported as configuration misma ); }); +test("VITE_RPC_URL with embedded credentials is rejected without echoing credentials", () => { + for (const url of [ + "https://webuser:websecret@custom-soroban.example.com", + "https://webuser@custom-soroban.example.com", + "https://:websecret@custom-soroban.example.com", + ]) { + const issues = validatePublicConfig({ + VITE_RPC_URL: url, + VITE_NETWORK_PASSPHRASE: "Custom Network", + VITE_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX", + }); + const credIssue = issues.find( + (i) => i.key === "VITE_RPC_URL" && i.message.includes("must not contain credentials"), + ); + assert.ok(credIssue, `expected credential issue for ${url}`); + assert.doesNotMatch(credIssue.message, /webuser|websecret/); + } +}); + diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index 2c4d8cf..dd98412 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -98,6 +98,25 @@ export function validatePublicConfig( key: "VITE_RPC_URL", message: `VITE_RPC_URL is not a valid URL (${rpcUrl}). It must start with http:// or https://.`, }); + } else { + try { + const parsed = new URL(normalized); + if (parsed.username !== "" || parsed.password !== "") { + issues.push({ + key: "VITE_RPC_URL", + message: + "VITE_RPC_URL must not contain credentials (username or password).", + }); + } + } catch { + if (/^https?:\/\/[^/\s?#]*@/i.test(normalized)) { + issues.push({ + key: "VITE_RPC_URL", + message: + "VITE_RPC_URL must not contain credentials (username or password).", + }); + } + } } } diff --git a/apps/web/src/passkey-config.ts b/apps/web/src/passkey-config.ts index 870b677..7cc7d91 100644 --- a/apps/web/src/passkey-config.ts +++ b/apps/web/src/passkey-config.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors import type { CommitParams, SubRosaClient } from "@sub-rosa/sdk"; -import { validatePasskeySession } from "@sub-rosa/sdk"; +import { assertNoUrlCredentials, validatePasskeySession } from "@sub-rosa/sdk"; /** Public testnet smart-wallet WASM (passkey-kit demo). Not a secret. */ export const PASSKEY_TESTNET_WALLET_WASM_HASH = @@ -16,6 +16,11 @@ const env: Record = export const PASSKEY_RPC_URL = env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; +export function resolvePasskeyRpcUrl(rawUrl: string = PASSKEY_RPC_URL): string { + assertNoUrlCredentials(rawUrl, "VITE_RPC_URL"); + return rawUrl.trim(); +} + export const PASSKEY_NETWORK_PASSPHRASE = env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 8a74a86..03e78d3 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 { + assertNoUrlCredentials, validateContractNetwork, validatePasskeySession, type PasskeySessionBinding, @@ -212,6 +213,7 @@ export class SubRosaClient { constructor(config: SubRosaClientConfig) { + assertNoUrlCredentials(config.rpcUrl, "rpcUrl"); const allowHttp = config.allowHttp ?? false; if (/^http:\/\//i.test(config.rpcUrl) && !allowHttp) { throw new SubRosaClientConfigError( 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..bbf99fe 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -71,6 +71,12 @@ export { type ProveEscrowConservationOptions, } from "./conservation.js"; export { + assertNoUrlCredentials, + describeNetworkPassphraseMismatch, + hasUrlCredentials, + networkDisplayName, + networkPassphrasesMatch, + SubRosaNetworkPassphraseMismatchError, validateContractNetwork, validatePasskeySession, type ContractNetworkValidationConfig, diff --git a/packages/sdk/src/network.test.ts b/packages/sdk/src/network.test.ts index 1edb817..6662433 100644 --- a/packages/sdk/src/network.test.ts +++ b/packages/sdk/src/network.test.ts @@ -2,10 +2,19 @@ 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 { KeeperStatusClient } from "./status-client.js"; import { + assertNoUrlCredentials, + describeNetworkPassphraseMismatch, + hasUrlCredentials, + networkDisplayName, + networkPassphrasesMatch, + SubRosaNetworkPassphraseMismatchError, validateContractNetwork, validatePasskeySession, type NetworkValidationServer, @@ -245,3 +254,57 @@ describe("validatePasskeySession", () => { }); }); +describe("assertNoUrlCredentials", () => { + it("accepts clean HTTP and HTTPS RPC URLs", () => { + assert.equal(hasUrlCredentials("https://soroban-testnet.stellar.org"), false); + assert.equal(hasUrlCredentials("http://localhost:8000/rpc"), false); + assert.doesNotThrow(() => + assertNoUrlCredentials("https://soroban-testnet.stellar.org", "rpcUrl"), + ); + }); + + it("rejects username and password URLs in SDK and status client without echoing credentials", () => { + for (const url of [ + "https://alice:secret123@rpc.example.test", + "https://alice@rpc.example.test", + "https://:secret123@rpc.example.test", + "http://admin:pass@localhost:8000", + ]) { + assert.equal(hasUrlCredentials(url), true); + assert.throws( + () => assertNoUrlCredentials(url, "rpcUrl"), + (err: unknown) => { + assert.ok(err instanceof SubRosaClientConfigError); + assert.match(err.message, /must not contain credentials/); + assert.doesNotMatch(err.message, /alice|secret123|admin|pass/); + return true; + }, + ); + assert.throws( + () => + new SubRosaClient({ + rpcUrl: url, + networkPassphrase: TESTNET, + contractId: CONTRACT_ID, + allowHttp: true, + }), + (err: unknown) => { + assert.ok(err instanceof SubRosaClientConfigError); + assert.match(err.message, /must not contain credentials/); + assert.doesNotMatch(err.message, /alice|secret123|admin|pass/); + return true; + }, + ); + assert.throws( + () => new KeeperStatusClient({ baseURL: url }), + (err: unknown) => { + assert.ok(err instanceof SubRosaClientConfigError); + assert.match(err.message, /must not contain credentials/); + assert.doesNotMatch(err.message, /alice|secret123|admin|pass/); + return true; + }, + ); + } + }); +}); + diff --git a/packages/sdk/src/network.ts b/packages/sdk/src/network.ts index 2cbdefd..95b77bc 100644 --- a/packages/sdk/src/network.ts +++ b/packages/sdk/src/network.ts @@ -1,10 +1,57 @@ import { Contract, rpc } from "@stellar/stellar-sdk"; import { + SubRosaClientConfigError, SubRosaNetworkMismatchError, SubRosaSessionMismatchError, } from "./errors.js"; import { normalizeSorobanContractId } from "./ids.js"; +/** + * Return true when `rawUrl` parses as a URL with a non-empty `username` or + * `password` component (or contains an authority `@` before the path). + */ +export function hasUrlCredentials(rawUrl: string): boolean { + const trimmed = rawUrl.trim(); + if (!trimmed) return false; + try { + const parsed = new URL(trimmed); + return parsed.username !== "" || parsed.password !== ""; + } catch { + return /^[a-z][a-z0-9+.-]*:\/\/[^/\s?#]*@/i.test(trimmed); + } +} + +/** + * Validate that an RPC or service endpoint URL uses HTTP(S) and does not embed + * a `username` or `password`. Throws {@link SubRosaClientConfigError} without + * echoing any credential fragments. + */ +export function assertNoUrlCredentials( + rawUrl: string, + label = "rpcUrl", +): URL { + const trimmed = typeof rawUrl === "string" ? rawUrl.trim() : ""; + if (!trimmed) { + throw new SubRosaClientConfigError(`${label}: must be a non-empty HTTP or HTTPS URL`); + } + if (hasUrlCredentials(trimmed)) { + throw new SubRosaClientConfigError(`${label}: must not contain credentials`); + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new SubRosaClientConfigError(`${label}: must be a valid HTTP or HTTPS URL`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new SubRosaClientConfigError(`${label}: must use http:// or https://`); + } + if (parsed.username !== "" || parsed.password !== "") { + throw new SubRosaClientConfigError(`${label}: must not contain credentials`); + } + 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..0a9e6df 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", @@ -51,6 +50,7 @@ const EXPECTED_EXPORTS = [ "assertDeploymentMatches", "assertMainnetConfirmed", "assertMicroAmounts", + "assertNoUrlCredentials", "assertReadinessForExecute", "assertSealedBid", "classifyRoundStatus", @@ -61,8 +61,11 @@ const EXPECTED_EXPORTS = [ "createSacBalanceReader", "defaultMainnetReadinessInput", "deploymentChecks", + "describeNetworkPassphraseMismatch", + "diffContractErrorMapping", "evaluatePreflight", "evaluateEscrowConservation", + "expectedRoundEventSequence", "fetchContractWasmHash", "fetchKeeperStatus", "fixtureDeployment", @@ -70,6 +73,7 @@ const EXPECTED_EXPORTS = [ "formatReadinessReport", "getRoundContractError", "hasBlockingFailures", + "hasUrlCredentials", "isActiveRoundStatus", "isErrorRoundStatus", "isEscrowConserved", @@ -77,8 +81,10 @@ const EXPECTED_EXPORTS = [ "isKeeperRoundSettlementPending", "isKeeperRoundTerminal", "isRoundContractErrorRetryable", + "isRoundPhase", "isTerminalRoundStatus", "nativeXlmSacId", + "networkDisplayName", "parseMainnetManifest", "parseMainnetReadinessFixture", "readLiveDeployment", @@ -86,11 +92,16 @@ const EXPECTED_EXPORTS = [ "networkPassphrasesMatch", "normalizeRoundId", "normalizeSorobanContractId", - "parseMicroStroops", "parseReceipt", "proveEscrowConservationFromPages", "redactReceipt", "redactUrlUserinfo", + "ROUND_EVENT_LIFECYCLE_ORDER", + "ROUND_EVENT_PHASE_BY_NAME", + "ROUND_EVENT_PHASE_RANK", + "ROUND_PHASES", + "ROUND_PHASE_LABELS", + "roundPhaseLabel", "roundStatusLabel", "runMainnetReadiness", "summarizeDeploymentValue", @@ -101,6 +112,7 @@ const EXPECTED_EXPORTS = [ "validateAssetConfigs", "validateEncryptedBlob", "validateContractNetwork", + "validatePasskeySession", "validateSealedBid", "verifyReceipt", "verifyReceiptEvents", diff --git a/packages/sdk/src/status-client.ts b/packages/sdk/src/status-client.ts index a9ca93e..90a736b 100644 --- a/packages/sdk/src/status-client.ts +++ b/packages/sdk/src/status-client.ts @@ -6,6 +6,7 @@ import { normalizeError } from "@sub-rosa/logging/errors"; import { systemScheduler, type Scheduler } from "@sub-rosa/time"; +import { assertNoUrlCredentials } from "./network.js"; import type { KeeperHealthResponse, KeeperStatusResponse, @@ -115,6 +116,7 @@ export class KeeperStatusClient { #lastSnapshot: KeeperStatusResponse | null = null; constructor(opts: StatusClientOptions) { + assertNoUrlCredentials(opts.baseURL, "baseURL"); this.baseURL = opts.baseURL; this.fetchImpl = opts.fetchImpl ?? globalThis.fetch; this.headers = opts.headers ?? {}; diff --git a/services/appraisal-api/src/client.ts b/services/appraisal-api/src/client.ts index d7c2e3a..01bca50 100644 --- a/services/appraisal-api/src/client.ts +++ b/services/appraisal-api/src/client.ts @@ -467,8 +467,29 @@ export function assertPaymentQuoteAllowed( } } } +function assertUrlHasNoCredentials(rawUrl: string, label: string): void { + let parsed: URL; + try { + parsed = new URL(rawUrl.trim()); + } catch { + if (/^[a-z][a-z0-9+.-]*:\/\/[^/\s?#]*@/i.test(rawUrl.trim())) { + throw new Error(`${label}: must not contain credentials`); + } + throw new Error(`${label}: must be a valid HTTP or HTTPS URL`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`${label}: must use http:// or https://`); + } + if (parsed.username !== "" || parsed.password !== "") { + throw new Error(`${label}: must not contain credentials`); + } +} + /** Build a paid-fetch function bound to a payer wallet. */ export function createPaidFetch(config: PaidClientConfig) { + if (config.rpcUrl !== undefined) { + assertUrlHasNoCredentials(config.rpcUrl, "rpcUrl"); + } const network = config.network ?? "stellar:testnet"; const signer = createEd25519Signer(config.secret, network); const rpcConfig = config.rpcUrl ? { url: config.rpcUrl } : undefined; diff --git a/services/keeper/src/dry-run.test.ts b/services/keeper/src/dry-run.test.ts index 4cd0d7c..1df16e6 100644 --- a/services/keeper/src/dry-run.test.ts +++ b/services/keeper/src/dry-run.test.ts @@ -99,6 +99,29 @@ describe("parseKeeperRunConfig", () => { /MAX_WAIT_SECONDS must be a non-negative finite number/, ); }); + + test("rejects credential-bearing RPC_URL values without echoing credentials", () => { + for (const rpcUrl of [ + "https://keeper-user:keeper-pass@rpc.example.com", + "https://keeper-user@rpc.example.com", + "https://:keeper-pass@rpc.example.com", + ]) { + assert.throws( + () => + parseKeeperRunConfig({ + ROUND_CONTRACT_ID: CONTRACT_ID, + KEEPER_DRY_RUN: "true", + RPC_URL: rpcUrl, + }), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.match(err.message, /RPC_URL: must not contain credentials/); + assert.doesNotMatch(err.message, /keeper-user|keeper-pass/); + return true; + }, + ); + } + }); }); describe("decideKeeperDryRunAction", () => { diff --git a/services/keeper/src/dry-run.ts b/services/keeper/src/dry-run.ts index 6a2ae05..86fa40c 100644 --- a/services/keeper/src/dry-run.ts +++ b/services/keeper/src/dry-run.ts @@ -1,8 +1,13 @@ // Copyright (c) 2026 Sub Rosa contributors -import type { BidState, Round, SubRosaClient } from "@sub-rosa/sdk"; +import { + assertNoUrlCredentials, + type BidState, + type Round, + type SubRosaClient, +} from "@sub-rosa/sdk"; import { systemClock } from "@sub-rosa/time"; -import { VOID_GRACE_SECONDS } from "./keeper.js"; +import { VOID_GRACE_SECONDS } from "./settlement-guard.js"; import { CHECKPOINT_VERSION, DEFAULT_CHECKPOINT_PATH, @@ -148,10 +153,13 @@ export function parseKeeperRunConfig( ); } + const rpcUrl = env.RPC_URL?.trim() || DEFAULT_RPC_URL; + assertNoUrlCredentials(rpcUrl, "RPC_URL"); + return { contractId: requiredEnv(env, "ROUND_CONTRACT_ID"), roundId: parseRoundId(env.ROUND_ID), - rpcUrl: env.RPC_URL?.trim() || DEFAULT_RPC_URL, + rpcUrl, networkPassphrase: env.NETWORK_PASSPHRASE?.trim() || DEFAULT_NETWORK_PASSPHRASE, dryRun, diff --git a/services/keeper/src/serve.ts b/services/keeper/src/serve.ts index 6a9fa9a..2cd123a 100644 --- a/services/keeper/src/serve.ts +++ b/services/keeper/src/serve.ts @@ -26,7 +26,7 @@ const diagnostics = createLogger("services.keeper.src.serve"); // KEEPER_LEASE_MS round lease duration (default 120000) import { Keypair } from "@stellar/stellar-sdk"; -import { SubRosaClient } from "@sub-rosa/sdk"; +import { assertNoUrlCredentials, SubRosaClient } from "@sub-rosa/sdk"; import { quicknet } from "@sub-rosa/tlock"; import { KeeperCheckpointStore } from "./checkpoint.js"; @@ -46,6 +46,7 @@ async function main() { const pollMs = Number(process.env.WATCH_POLL_MS ?? "15000"); const contractId = reqEnv("ROUND_CONTRACT_ID"); const rpcUrl = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + assertNoUrlCredentials(rpcUrl, "RPC_URL"); const networkPassphrase = process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; const keeperSecret = reqEnv("KEEPER_SECRET"); diff --git a/services/keeper/src/watch.ts b/services/keeper/src/watch.ts index eda053a..6fdb0b1 100644 --- a/services/keeper/src/watch.ts +++ b/services/keeper/src/watch.ts @@ -18,7 +18,7 @@ const diagnostics = createLogger("services.keeper.src.watch"); // KEEPER_LEASE_MS round lease duration (default 120000) import { Keypair } from "@stellar/stellar-sdk"; -import { SubRosaClient } from "@sub-rosa/sdk"; +import { assertNoUrlCredentials, SubRosaClient } from "@sub-rosa/sdk"; import { quicknet } from "@sub-rosa/tlock"; import { KeeperCheckpointStore } from "./checkpoint.js"; @@ -37,6 +37,7 @@ async function main() { const pollMs = Number(process.env.WATCH_POLL_MS ?? "15000"); const contractId = reqEnv("ROUND_CONTRACT_ID"); const rpcUrl = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + assertNoUrlCredentials(rpcUrl, "RPC_URL"); const networkPassphrase = process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; const keeperSecret = reqEnv("KEEPER_SECRET");