Skip to content

Oracle provider sandbox, graceful pipeline shutdown, endpoint failover, and network config validation - #468

Merged
OxDev-max merged 5 commits into
Refract-Protocol:mainfrom
lekescrew22:feat/oracle-sandbox-shutdown-endpoints-network-guard
Oct 23, 2026
Merged

OxDev-max merged 5 commits into
Refract-Protocol:mainfrom
lekescrew22:feat/oracle-sandbox-shutdown-endpoints-network-guard

Conversation

@lekescrew22

Copy link
Copy Markdown
Contributor

Summary

Implements four oracle and operations issues, one commit each:

Commit Issue
feat(oracle): add provider sandbox for signed readings and corroboration #253
feat(oracle): drain the oracle pipeline gracefully on SIGTERM/SIGINT #252
feat(oracle): let provider adapters fail over across ordered endpoints #254
feat(config): require one consistent Stellar network at startup #251

The new code is self-contained modules with unit tests (63 tests in 5 new suites), wired into AppModule / main.ts.

Reviewer note: repo state. main currently can't build. package.json is empty, and oracle.module.ts, oracle.service.ts, oracle-publisher.service.ts and db.module.ts are empty files emptied by earlier PRs. app.module.ts also has truncated lines. This PR doesn't try to repair those. As a result, some wiring points could not be compiled here (see Verification), and the OracleService adapters don't call the new endpoint registry yet.


#253 Provider sandbox

ORACLE_SANDBOX_MODE=true mounts /sandbox/oracle/*. Outside providers can register a key, submit signed readings, and inspect what corroboration did with them. Nothing is published on-chain.

Done

  • Signed submissions (provider-submission.ts)
    • ed25519 over a canonical, versioned message.
    • The message includes a domain tag (sandbox / production), so a sandbox signature never verifies in production.
    • value is a decimal string, so the signed bytes match in every language.
  • Corroboration engine (corroboration.ts)
    • A pure function, so production can reuse it.
    • Uses each provider's latest reading in the window, then the median.
    • Outliers are readings past max(|median| × ratio, per-feed absolute floor).
    • The quorum is checked on the readings that agree.
  • Pipeline order: shape check → known feed → registered provider → freshness (stale / from future) → signature → per-provider rate limit → nonce replay → corroboration.
    • The signature is checked before the nonce and the rate limit, so forged requests can't use up a real provider's nonce or quota.
  • Inspect endpoints:
    • GET /sandbox/oracle/decisions returns the value, median, tolerance, agreeing and outlier providers, and contributing submissions.
    • GET /rejections returns typed reasons (invalid_shape, unknown_feed, unregistered_provider, stale, from_future, bad_signature, rate_limited, replayed_nonce, outlier, insufficient_agreement).
    • GET /feeds/:feed returns the readings in the current window.
  • Registration requires a signature, which proves the provider owns the key.
  • Isolation:
    • State is held in memory only. The module has no Postgres, Redis, Soroban or relayer dependency.
    • Boot fails if a relayer key is configured or STELLAR_NETWORK is mainnet.
  • Retention: decisions and rejections are purged after ORACLE_SANDBOX_RETENTION_MS (24h). Storage is bounded.
  • Docs and tooling: quickstart in docs/oracle-provider-sandbox.md, plus scripts/sandbox-sign-reading.ts to produce signed payloads.

Not done

  • No persisted "evidence namespace". Sandbox data lives only in memory, which gives isolation by construction, but nothing survives a restart.
  • No "trust tier that would have applied". The codebase has no trust-tier model yet.
  • No seed fixtures and no recorded end-to-end session.

#252 Graceful shutdown and in-flight draining

Found while implementing

  • DbService closed the Postgres pool in onModuleDestroy. Nest runs that hook before any drain, so in-flight evidence writes lost their connection.
  • main.ts called enableShutdownHooks() and registered its own SIGTERM/SIGINT handlers. As a result, app.close() ran twice.
  • Compose's default stop_grace_period is 10s, but the app's grace period is 30s. Docker sent SIGKILL mid-drain.

Done

  • OraclePipelineLifecycle runs in Nest's shutdown order:
    1. onModuleDestroy: stop starting polls.
    2. beforeApplicationShutdown: drain in-flight polls, publishes and evidence writes within ORACLE_SHUTDOWN_DRAIN_MS. This deadline is capped 5s below SHUTDOWN_GRACE_PERIOD_MS. Then release the leader lock.
    3. onApplicationShutdown: Postgres and Redis close.
  • Abandoned work: if the deadline passes, each abandoned task is logged with its id (e.g. oracle_event:MarketCrash) and the process exits 1.
  • Repeated signal: a second SIGTERM/SIGINT forces an immediate exit.
  • Leader lock:
    • LocalLeaderLock is the default.
    • ORACLE_LEADER_LOCK=redis enables RedisLeaderLock, using SET NX PX with token-checked renew and release, so a replica whose lock expired can't delete the new leader's lock.
    • The lock has its own connection, which is closed after release.
  • Wiring:
    • OracleScheduler polls through runPoll() (skipped while shutting down or when not leader).
    • OracleEventRepository tracks its writes.
    • DbService now closes in onApplicationShutdown.
  • Docker: docker-compose.yml sets stop_grace_period: 40s and passes the deadlines; the Dockerfile has an explicit STOPSIGNAL SIGTERM.

Not done

  • The integration test that sends SIGTERM to a live process: the project can't build or boot right now. The drain, deadline and lock behaviour are covered by unit tests.
  • The publisher doesn't wrap on-chain submits in track("publish", …) yet, because oracle-publisher.service.ts is empty on main. A submit is still covered today because it runs inside a tracked poll.
  • ClaimScheduler is not wired.

#254 Per-adapter endpoint failover

Done

  • Endpoint lists: ORACLE_ENDPOINTS_<COINGECKO|DEFILLAMA|HORIZON> takes an ordered list of url or label=url entries (primary, mirrors, regions).
    • When unset, the existing single-URL settings are used, so nothing changes for current deployments.
    • Horizon's default follows STELLAR_NETWORK.
  • EndpointHealth: a circuit breaker driven by the outcomes of real calls (consecutive failures, latency EWMA, last success).
    • "Degraded" status expires after the cooldown, so a primary that had one bad call gets traffic back.
    • A down endpoint gets one trial call (probe) after its cooldown; the cooldown doubles on each failure, up to a cap.
  • ProviderEndpointSet.execute():
    • Tries the healthiest endpoint first; ties keep the declared order, so the primary is used whenever it is healthy.
    • Applies a per-attempt timeout with an AbortSignal, and fails over to the next endpoint within the same call.
    • Supports a shouldFailover opt-out for errors every endpoint would return (e.g. a 400).
    • When every endpoint is down, still tries the one whose cooldown ends first, so the feed doesn't stall.
  • Observability: demotion and restoration are logged; GET /api/v1/oracle/endpoints/health shows the current state with credentials and query strings redacted.
  • Documented rule: all endpoints in a set belong to one provider, and mirrors never count as independent sources in corroboration.
  • Separate from SOROBAN_RPC_URLS.

Not done

  • Health state is per process, not shared through Redis.
  • No Prometheus metrics yet; logs only.
  • OracleService adapters don't call the registry yet, because oracle.service.ts is empty on main. Adapting them is a short change: registry.get("coingecko").execute((baseUrl, signal) => …).

#251 Cross-network configuration validation

Done

  • Before Nest boots, assertNetworkConsistency() checks:
    • STELLAR_NETWORK is a known network (public/pubnet → mainnet), and is set explicitly whenever suffixed values exist.
    • No *_TESTNET/_FUTURENET/_MAINNET/_PUBLIC value is set for a non-selected network. Errors name the offending keys.
    • Suffixed and unsuffixed (and legacy POOL_CONTRACT_ID-style) values agree.
    • The passphrase matches the network.
    • RPC, SOROBAN_RPC_URLS and Horizon URLs don't point at another network, judged by known SDF hosts or a network word in the URL.
    • https is used on mainnet.
    • Contract IDs are valid and not reused across roles.
  • Live check with STELLAR_VALIDATE_ONCHAIN=true: RPC getNetwork and Horizon / must report the expected passphrase.
    • Unreachable endpoints only warn, so the static and network checks stay separate.
    • This adds to the existing StellarConfigValidator check rather than duplicating it.
  • Payout readiness: a warning (not an error) names the missing pieces (oracle ID, pool ID, relayer key) and the coverage types that therefore can't pay out.
  • Secrets: values are never echoed; for the relayer key, only presence is checked.
  • CLI: scripts/check-network-config.ts [--live] runs the same checks.
  • Docs: README configuration section with example failure output:
Stellar network configuration is inconsistent:
  - REFRACT_ORACLE_CONTRACT_ID_MAINNET is set for mainnet but STELLAR_NETWORK=testnet; remove it so only one network is configured
  - STELLAR_NETWORK_PASSPHRASE is the mainnet passphrase but STELLAR_NETWORK=testnet (expected "Test SDF Network ; September 2015")

Not done

  • No check:env npm script and no CI step. package.json is empty on main; once it is restored, add "check:env": "ts-node scripts/check-network-config.ts".
  • Implemented as plain validation functions rather than a Zod schema. The runtime and the CLI share the same function.

Type of change

  • New feature
  • Bug fix (shutdown ordering and double app.close(), Compose SIGKILL timing)
  • Tests / docs

Verification

The repo's own toolchain can't run (package.json is empty), so I tested in a separate harness with the same tsconfig strictness flags (strict, noUnusedLocals, noUnusedParameters):

PASS src/oracle/sandbox/corroboration.spec.ts
PASS src/oracle/sandbox/oracle-sandbox.service.spec.ts
PASS src/oracle/shutdown/oracle-pipeline-lifecycle.spec.ts
PASS src/oracle/endpoints/provider-endpoints.spec.ts
PASS src/config/network-consistency.spec.ts
Tests: 63 passed, 63 total

All new modules, controllers and Nest modules type-check. Edits to main.ts, app.module.ts, oracle.scheduler.ts and oracle-event.repository.ts could not be compiled, because those files import modules that are empty or missing on main.

Checklist

  • Tests added for each change
  • No secrets, keys, or .env values committed
  • .env.example, README and docs/ updated

Closes #251
Closes #252
Closes #253
Closes #254

🤖 Generated with Claude Code

lekescrew22 and others added 4 commits October 21, 2026 12:13
Adds a sandbox deployment mode (ORACLE_SANDBOX_MODE=true). In it, data
providers register a Stellar key, submit signed readings, and inspect the
corroboration decisions and rejections those readings produce. Nothing is
published on-chain and production feeds are untouched.

- Domain-separated ed25519 signing (sandbox signatures never verify in
  production), decimal-string values, per-provider nonce replay protection
- Pure corroboration engine: latest reading per provider in the window,
  median, outliers past a ratio/absolute tolerance, quorum check
- In-memory service with bounded storage and a submission trace response
- /sandbox/oracle API, signing script, and docs
- Boot guard: refuses to start with a relayer key or on mainnet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A container restart could interrupt a publish halfway, or close Postgres
before an evidence write committed. That left duplicate or missing
evidence.

- OraclePipelineLifecycle: stops new polls on onModuleDestroy, drains
  in-flight polls/publishes/evidence writes in beforeApplicationShutdown
  within ORACLE_SHUTDOWN_DRAIN_MS (capped below SHUTDOWN_GRACE_PERIOD_MS),
  then releases the leader lock and logs anything abandoned
- Leader lock: LocalLeaderLock by default, RedisLeaderLock
  (ORACLE_LEADER_LOCK=redis) with token-checked renew/release
- OracleScheduler polls through the lifecycle; OracleEventRepository
  tracks evidence writes
- DbService closes its pool in onApplicationShutdown, after the drain
- main.ts no longer calls enableShutdownHooks() alongside its own signal
  handlers, which made app.close() run twice

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each oracle data-source adapter (CoinGecko, DeFiLlama, Horizon) now has an
ordered list of endpoints: primary, mirrors, other regions. Calls go to the
healthiest one, so a single unreachable host no longer stalls a feed.

- EndpointHealth: circuit breaker driven by live call outcomes (consecutive
  failures, latency EWMA). Degraded status expires and a probe trial runs
  after cooldown, so a recovered primary gets traffic back
- ProviderEndpointSet.execute(): per-attempt timeout with AbortSignal,
  failover within one call, opt-out for non-retryable errors, last-resort
  try when every endpoint is down
- ORACLE_ENDPOINTS_<ADAPTER> config (`url` or `label=url`), falling back to
  the existing single-URL settings
- GET /api/v1/oracle/endpoints/health with credentials/query redacted

Separate from SOROBAN_RPC_URLS, which handles on-chain RPC failover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mixed-network configurations used to fail only at the first publish. Worse,
they could succeed against the wrong deployment. Bootstrap now checks
before any module starts:

- STELLAR_NETWORK is a known network (public/pubnet → mainnet), and is set
  explicitly whenever network-suffixed values exist
- No RPC/Horizon/passphrase/contract value is set for another network's
  suffix; suffixed and unsuffixed values agree
- Passphrase matches; RPC/Horizon URLs (including SOROBAN_RPC_URLS) don't
  point at another network, by known SDF host or network word in the URL;
  https required on mainnet
- Pool/oracle/policy contract IDs are valid and not reused across roles
- With STELLAR_VALIDATE_ONCHAIN=true, RPC getNetwork and Horizon / must
  return the expected passphrase

Every problem is reported at once. scripts/check-network-config.ts runs the
same checks before a deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@drips-wave

drips-wave Bot commented Oct 21, 2026

Copy link
Copy Markdown

@lekescrew22 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

…-shutdown-endpoints-network-guard

# Conflicts:
#	.env.example
#	src/app.module.ts
#	src/main.ts
#	src/oracle/oracle-event.repository.ts
#	src/oracle/oracle.scheduler.ts
@OxDev-max
OxDev-max merged commit e428b17 into Refract-Protocol:main Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants