Skip to content

fix: refuse readiness and /fund until the pool is seeded - #36

Open
tuliomir wants to merge 2 commits into
mainfrom
fix-ready-before-pool-seeded
Open

tuliomir wants to merge 2 commits into
mainfrom
fix-ready-before-pool-seeded

Conversation

@tuliomir

@tuliomir tuliomir commented Aug 4, 2026 •

Copy link
Copy Markdown
Collaborator

/ready returned 200 and /fund was served during the window between the genesis wallet syncing and the initial split seeding the UTXO pool. This shipped in v0.0.2.

isGenesisReady() flips true as soon as the wallet syncs, which happens well before runInitialSplit() completes. Readiness only knew about degraded, so for that whole window the service advertised itself as able to fund an empty pool.

Why this is worse than a confusing POOL_EXHAUSTED

A /fund request in that window triggers a background refill that reserves from the same candidate set the bootstrap's own split is drawing on. Losing that race fails the bootstrap into degraded, which never self-heals because bootPromise is memoized (#16).

So a client doing exactly the right thing — polling /ready, then funding — could permanently poison the service for its whole lifetime.

How it works

  • src/routes.ts — computeReadiness takes the lifecycle phase instead of a startupDegraded boolean and requires ready. The lossy boolean was the root cause: a phase collapsed to "is it degraded" cannot express "not finished". A new funding_initializing reason covers idle and initializing, and handleFund refuses the same phases so the two endpoints can never disagree. The wallet funds query now runs only at phase ready, where its answer can change the verdict.
  • src/startup.ts — exports StartupPhase so readiness can consume it losslessly.
  • src/genesis.service.ts, src/config.ts — the reward-lock wait and GENESIS_SYNC_TIMEOUT_MS are capped at a minute each. Both are near-immediate on a working stack, and a longer ceiling cannot rescue an unreachable fullnode or a network that has stopped producing blocks — it only delays the degraded verdict a developer needs in order to debug.
  • README.md, CLAUDE.md — readiness reason table and architecture notes updated.

phase === "ready" provably implies a seeded pool: setStartupState("ready") is the last statement of the bootstrap, after runInitialSplit(), which returns only when the pool holds at least one test UTXO and otherwise throws into degraded.

Behavioral change

The 503 window is longer than before, because it is now honest. /ready previously flipped 200 within seconds of genesis sync; it now waits for the bootstrap to finish seeding. With both timeouts capped at a minute the bootstrap settles within a few minutes at worst, but a healthcheck retry budget sized against the old behaviour may need raising. The README states the expectation explicitly.

/fund returns 503 SERVICE_NOT_READY (retryable) during the window rather than accepting work it cannot serve.

Acceptance criteria

  • /ready reports 503 funding_initializing while the bootstrap is idle or initializing, and /fund refuses the same phases.
  • degraded still reports funding_degraded — the transient reason must not shadow a state that never self-heals.
  • The wallet funds query is not issued outside phase ready.
  • bun run check passes.

Closes #25

Stack created with GitHub Stacks CLI • Give Feedback 💬

tuliomir and others added 2 commits August 4, 2026 13:57
`isGenesisReady()` flips true as soon as the genesis wallet syncs, which
happens well before `runInitialSplit()` seeds the pool. Readiness only
knew about `degraded`, so for that whole window `/ready` returned 200 and
`/fund` was served against an empty pool.

That is worse than a confusing POOL_EXHAUSTED. A request in the window
triggers a background refill that reserves from the same candidate set
the bootstrap's own split is drawing on; losing that race fails the
bootstrap into `degraded`, which never self-heals because `bootPromise`
is memoized. So a client doing the correct thing — polling /ready, then
funding — could permanently poison the service.

Readiness now takes the lifecycle phase instead of a `startupDegraded`
boolean, and requires `ready`. The lossy boolean was the root of it: a
phase collapsed to "is it degraded" cannot express "not finished". The
new `funding_initializing` reason covers `idle` and `initializing`, and
`/fund` refuses the same phases so the two can never disagree. The funds
query now runs only at phase `ready`, where its answer can change the
verdict.

Behavioural note for consumers: the 503 window is now much longer.
Genesis sync plus the block-reward height lock are waited out inside the
bootstrap, so a cold private network can sit at `funding_initializing`
for tens of minutes where `/ready` previously flipped 200 within seconds
of sync. That is the honest answer, but healthcheck retry budgets sized
against the old behaviour will need raising — README says so explicitly.

Closes #25

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both waits are near-immediate on a stack that is actually working:
genesis sync against a reachable fullnode returns in seconds, and a
block-reward height lock clears in seconds once blocks are being
produced. A minute leaves headroom for both.

A longer ceiling buys nothing. It cannot rescue a fullnode that is
unreachable or a network that has stopped producing blocks — it only
holds /ready and /fund at a transient `funding_initializing` while a
developer waits for a verdict that is not coming. Reaching `degraded`
promptly is the signal that something needs debugging.

This also bounds the readiness window the previous commit introduced:
the bootstrap now settles within a few minutes at worst instead of
tens, so healthcheck budgets stay reasonable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 4, 2026 18:20
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@tuliomir, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 122e65d9-c9b5-4f01-b0d1-4a13e60857ae

📥 Commits

Reviewing files that changed from the base of the PR and between fa2faec and a2bb1a1.

📒 Files selected for processing (12)
  • .env.example
  • CLAUDE.md
  • README.md
  • __tests__/src/config.test.ts
  • __tests__/src/fund-endpoint-error-codes.test.ts
  • __tests__/src/fund-endpoint.test.ts
  • __tests__/src/readiness.test.ts
  • __tests__/src/routes-readiness.test.ts
  • src/config.ts
  • src/genesis.service.ts
  • src/routes.ts
  • src/startup.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tuliomir tuliomir added the bug Something isn't working label Aug 4, 2026
@tuliomir tuliomir self-assigned this Aug 4, 2026
@tuliomir tuliomir changed the title fix ready before pool seeded fix: refuse readiness and /fund until the pool is seeded Aug 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a readiness/funding race where the service could report /ready before the funding bootstrap finished seeding the UTXO pool, allowing /fund to run early and potentially push the bootstrap into a permanently degraded state. It does so by gating readiness and /fund on the funding subsystem’s lifecycle StartupPhase (must be ready), adding an explicit funding_initializing readiness reason for the pre-seed window, and tightening related bootstrap timeouts with updated documentation and tests.

Changes:

  • Make readiness depend on the full startup lifecycle phase (not just a degraded boolean), introducing funding_initializing until phase ready.
  • Refuse /fund unless the startup phase is ready (and keep degraded explicitly non-ready).
  • Tighten default genesis sync / reward-unlock timeouts and update docs + tests accordingly.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/startup.ts Exports StartupPhase so readiness and tests can key off the lifecycle phase.
src/routes.ts Gates /ready and /fund on StartupPhase, adds funding_initializing, and avoids wallet-funds queries before bootstrap completion.
src/genesis.service.ts Reduces default reward-unlock wait timeout to fail faster into degraded when blocks aren’t progressing.
src/config.ts Reduces default GENESIS_SYNC_TIMEOUT_MS to 60s (bounded 1s–600s) to surface misconfigurations faster.
README.md Documents the new readiness semantics (funding_initializing until bootstrap completes) and updated operational guidance.
CLAUDE.md Updates architecture notes to match the new readiness/phase gating.
.env.example Updates documented default for GENESIS_SYNC_TIMEOUT_MS and guidance for overrides.
tests/src/routes-readiness.test.ts Adds coverage ensuring funding_initializing short-circuits without querying wallet funds.
tests/src/readiness.test.ts Updates pure readiness tests for phase-based gating and adds initializing/idle cases.
tests/src/fund-endpoint.test.ts Adds assertions that /fund returns 503 while initializing or idle.
tests/src/fund-endpoint-error-codes.test.ts Ensures tests pin startup state to ready so domain error mapping is exercised.
tests/src/config.test.ts Updates default timeout expectation to 60s.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/routes.ts
Comment on lines +387 to +391
if (startupPhase !== "ready") {
return jsonErrorFromService(
new ServiceNotReadyError("funding subsystem is still initializing"),
);
}
@tuliomir
tuliomir requested a review from pedroferreira1 August 5, 2026 16:01
@tuliomir tuliomir moved this from Todo to In Progress (Done) in Hathor Network Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

Status: In Progress (Done)

Development

Successfully merging this pull request may close these issues.

/ready reports 200 before the UTXO pool is seeded

2 participants