Skip to content

fix: covering balance cache - #5610

Merged
sbackend123 merged 8 commits into
masterfrom
fix/chequebook-covering-balance-cache
Oct 26, 2026
Merged

sbackend123 merged 8 commits into
masterfrom
fix/chequebook-covering-balance-cache

Conversation

@sbackend123

@sbackend123 sbackend123 commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Checklist

  • I have read the coding guide.
  • [] My change requires a documentation update, and I have done it.
  • I have added tests to cover my changes.
  • I have filled out the description and linked the related issues.

Description

Adds an in-memory cache for the chequebook covering balance (balance + totalPaidOut) so cheque issuance no longer does two live RPC calls under the global mutex on every Issue.

Known corner cases
a) Deposit may stay invisible until the cache expires
If the cache is loaded (or reloaded) before a deposit is mined, the node can keep using a stale covering balance for up to 5 minutes and refuse to issue cheques (ErrOutOfFunds). Impact is temporary under-issuance only. In practice the node is already struggling to settle when the chequebook is empty; an extra few minutes is unlikely to change the outcome meaningfully.

b) Withdrawal + cache refresh can allow an uncovered cheque
After Withdraw, the cache is reduced locally. An uncovered cheque is possible only if the cache is refreshed from chain after the withdrawal is broadcast but before it is mined, so the pending withdrawal is missing from the reloaded value.

That requires either:

  • a short window where the cache is invalidated/reloaded while the tx is still pending (e.g. a deposit invalidation), or
  • a stuck withdrawal that remains unmined longer than the 5-minute TTL

Open API Spec Version Changes (if applicable)

Motivation and Context (Optional)

Related Issue (Optional)

#5570

Screenshots (if appropriate):

Results:

Screenshot from 2026-09-13 21-51-45

AI Disclosure

  • This PR contains code that has been generated by an LLM.
  • I have reviewed the AI generated code thoroughly.
  • I possess the technical expertise to responsibly review the code generated in this PR.

@sbackend123
sbackend123 marked this pull request as ready for review September 13, 2026 20:01

if amount.Cmp(big.NewInt(0).Sub(availableBalance, s.totalIssuedReserved)) > 0 {
s.metrics.OutOfFunds.Inc()
return nil, ErrOutOfFunds

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"On apparent shortfall, force one refresh before returning ErrOutOfFunds." Without it, a node that runs dry keeps refusing cheques for up to 5 min after funds arrive. That covers both our own deposits (see the comment on L126) and external top-ups. This line is unchanged, but availableBalance is now cached, so it can refuse based on a balance up to 5 min old.

return common.Hash{}, err
}

s.invalidateCoveringBalance()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This runs right after the transfer is broadcast, before it's mined. POST /chequebook/deposit never calls WaitForDeposit. So the next Issue (usually within milliseconds on a busy node) reloads the pre-deposit balance and caches it for 5 minutes.

Reproduced with a simulated chain:

Empty chequebook.
Deposit 100 via Deposit.
The tx gets mined.
Issue(10) still returns ErrOutOfFunds.
At the same time, GET /chequebook/balance shows totalBalance: 100, availableBalance: 0. On master, the same Issue succeeds.

defer s.lock.Unlock()

if s.coveringBalance != nil {
s.coveringBalance.Sub(s.coveringBalance, amount)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The deduction only lives in the cached value, so any reload throws it away while the withdrawal is still pending. A reload happens on deposit invalidation, on TTL expiry, or on the shortfall refresh from L219.

Repro:

Balance 100, issue 10.
Withdraw(90) (pending).
Deposit(1) (pending).
Issue(50) succeeds.
After both txs are mined: 60 issued against 11 covering.

}

if amount.Cmp(big.NewInt(0).Sub(availableBalance, s.totalIssuedReserved)) > 0 {
s.metrics.OutOfFunds.Inc()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Before refusing here, could we reload the covering balance once? Something like:

go
if amount.Cmp(new(big.Int).Sub(availableBalance, s.totalIssuedReserved)) > 0 {
        s.coveringBalance = nil // force a reload: an external top-up may not be cached yet
        if availableBalance, err = s.availableBalance(ctx); err != nil {
                return nil, err
        }
        if amount.Cmp(new(big.Int).Sub(availableBalance, s.totalIssuedReserved)) > 0 {
                s.metrics.OutOfFunds.Inc()
                return nil, ErrOutOfFunds
        }
}

That covers top-ups made outside the node, which invalidateCoveringBalance never sees.

}

func (s *service) getOrLoadCoveringBalance(ctx context.Context) (*big.Int, error) {
now := time.Now().UTC()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: .UTC() isn't needed. now.Sub(s.coveringBalanceAt) already uses the monotonic clock reading in time.Time, and .UTC() only changes the time zone used for display. Plain time.Now() reads more clearly. Same at line 403.

func (s *service) getOrLoadCoveringBalance(ctx context.Context) (*big.Int, error) {
now := time.Now().UTC()
if s.coveringBalance != nil && now.Sub(s.coveringBalanceAt) < coveringBalanceCacheValidity {
s.metrics.CoveringBalanceCacheHit.Inc()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: hit and miss counters are fine. For operators, a gauge of the current covering balance, or of the available balance after each cheque, would probably be more useful when diagnosing out_of_funds.

sbackend123 and others added 5 commits October 21, 2026 21:38
CI builds the PR merge, so tests that arrived with the base still
called chequebook.New without blockTime.

Co-authored-by: Cursor <cursoragent@cursor.com>
if errors.Is(err, transaction.ErrTransactionCancelled) {
s.logger.Warning("withdrawal transaction was not mined", "tx", txHash, "amount", amount, "error", err)
s.lock.Lock()
delete(s.pendingWithdrawals, txHash)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we have instead of these 3 lines just s.releaseMinedWithdrawal(txHash) ?

@sbackend123
sbackend123 merged commit 6d212fc into master Oct 26, 2026
17 checks passed
@sbackend123
sbackend123 deleted the fix/chequebook-covering-balance-cache branch October 26, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants