Skip to content

fix(pseudosettle): bound the first refreshment of a connection#5531

Closed
mfw78 wants to merge 2 commits into
ethersphere:masterfrom
nxm-rs:fix/pseudosettle-first-contact-allowance
Closed

fix(pseudosettle): bound the first refreshment of a connection#5531
mfw78 wants to merge 2 commits into
ethersphere:masterfrom
nxm-rs:fix/pseudosettle-first-contact-allowance

Conversation

@mfw78

@mfw78 mfw78 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

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

peerAllowance computes the acceptable incoming payment as (now - lastSettlementTime) * refreshRate. With no stored settlement record lastSettlementTime defaults to 0, so the first refreshment from a peer is granted the whole Unix epoch scaled by the refresh rate and forgives its entire outstanding debt in one go. The same holds on reconnection: the stored record survives the disconnect, so a peer returning after an hour is granted an hour of allowance for a connection that is seconds old.

The bound cannot be applied to the recipient alone. NotifyRefreshmentSent recomputes the expectation as min(allegedInterval * refreshRate, attemptedAmount) and blocklists the peer when the accepted amount falls short, and allegedInterval is derived from the payer's own stored record, which defaults to 0 in exactly the same way. Both ends defaulting to the epoch is what makes them agree today, so tightening one side alone would make a patched recipient accept less than every peer expects and be blocklisted on the first refreshment.

This bounds the first refreshment of a connection to a single refresh interval, applied identically on the recipient and the payer. Refreshments after the first use the stored timestamps as before, and reconnection is treated as a first refreshment so allowance no longer accrues while a peer is disconnected.

Anchoring at the connection time was considered and dropped. The quantity being bounded is of the same order as the clock skew the protocol already tolerates, 2 seconds in ErrTimeOutOfSyncRecent against a default payment threshold of 3 seconds of refresh rate, so a bound derived from two independently measured clocks would be unreliable, and slack added to absorb the skew would be claimable on every refreshment. Treating an absent or pre-connection timestamp as one interval needs no clock comparison between peers and is identical on both ends by construction.

On rollout: an unpatched payer still expects the full attempted amount on first contact, so it will blocklist a patched recipient for roughly (debt + paymentThreshold) / refreshRate seconds on each new peer until the network has upgraded. The payer half is a pure relaxation and can never cause a blocklist, so it can be released on its own first with the recipient bound following later if you would rather not carry that. Happy to split it that way.

One correction to #5530: the allowance is capped by the peer's debt, which the recipient already caps at disconnectLimit, so the exposure is one payment threshold plus tolerance per new peer identity rather than an unbounded amount. Still worth closing off given identities are cheap, but the issue overstates the magnitude.

Open API Spec Version Changes (if applicable)

None.

Motivation and Context (Optional)

See #5530.

Related Issue (Optional)

Closes #5530.

Screenshots (if appropriate):

N/A

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.

The time-based allowance a node grants an incoming refreshment is
computed as (now - lastSettlementTime) * refreshRate. On the first
refreshment from a peer there is no stored settlement time, so the
anchor defaulted to the zero Unix epoch, making the allowance
(now - 0) * refreshRate. This lets the very first refreshment forgive
an effectively unbounded debt, decoupled from how long the peer has
actually been connected.

Anchor the first-contact allowance to the peer's connection time
instead, so the initial refreshment only forgives debt accrued over
the elapsed connected time. Subsequent settlements are unchanged.
@mfw78
mfw78 marked this pull request as draft July 8, 2026 12:29
@mfw78
mfw78 marked this pull request as ready for review July 8, 2026 12:43
@gacevicljubisa

Copy link
Copy Markdown
Member

What about the payer side, should it match the peerAllowance?

@gacevicljubisa

Copy link
Copy Markdown
Member

What about the payer side, should it match the peerAllowance?

@mfw78

The payer derives its allowance expectation from the same defaulted timestamp
as the recipient, so bounding only the recipient makes the payer expect more
than is granted and blocklist it. Bound the first refreshment of a connection
to a single refresh interval on both sides instead of anchoring at connection
time, which would couple the bound to two independently measured clocks.

Reconnection is treated as a first refreshment, so allowance no longer accrues
while a peer is disconnected.
@mfw78 mfw78 closed this Jul 23, 2026
@mfw78 mfw78 changed the title fix(pseudosettle): bound first-contact allowance to connection time fix(pseudosettle): bound the first refreshment of a connection Jul 23, 2026
@mfw78

mfw78 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch, it does have to match, and leaving it unmatched would have been worse than the original bug.

NotifyRefreshmentSent recomputes the expectation as min(allegedInterval * refreshRate, attemptedAmount) and blocklists the peer when the accepted amount falls short. allegedInterval is paymentAck.Timestamp - lastTime.Timestamp, and on first contact the payer's stored record is absent so its timestamp defaults to 0 in exactly the same way the recipient's does. Both ends defaulting to the epoch is precisely what makes them agree today. Bounding the recipient alone would have made a patched node accept less than every peer expects and get blocklisted on the first refreshment with each new peer.

Pushed a change that applies the bound on both sides. I dropped the connection-time anchor while doing so: the amount being bounded is of the same order as the clock skew the protocol already tolerates, 2 seconds in ErrTimeOutOfSyncRecent against a default payment threshold of 3 seconds of refresh rate, so a bound derived from two independently measured clocks would be unreliable, and any slack added to absorb that skew would be claimable on every refreshment rather than once. Both sides now treat an absent or pre-connection timestamp as exactly one refresh interval, which needs no clock comparison between peers and is identical on both ends by construction.

That also covers reconnection, which the first version missed. The stored record survives a disconnect, so a peer returning after an hour was granted an hour of allowance against a connection seconds old. It now gets a single interval, same as first contact.

On the test side, testCaseAccepted asserts the payer's alleged interval against the interval the recipient granted on, since that equality is the invariant keeping the two ends from diverging. Reverting either half of the change fails it: without the recipient bound the full 1000 intervals of debt are accepted, without the payer change the payer alleges an interval of 1000000 against the recipient's 1.

Two things worth your call:

Rollout. An unpatched payer still expects the full attempted amount on first contact, so it will blocklist a patched recipient for roughly (debt + paymentThreshold) / refreshRate seconds on each new peer until the network has upgraded. Short and self-clearing, but it recurs per peer. The payer half is a pure relaxation and can never cause a blocklist, so it could ship on its own first with the recipient bound following in a later release. Say the word and I will split it.

Magnitude. #5530 overstates this and I would rather correct it than have it read as more than it is. The allowance is capped by the peer's debt, which the recipient already caps at disconnectLimit, so the exposure is one payment threshold plus tolerance per new peer identity, not an unbounded amount. Worth closing off given identities are cheap, but it is not the unbounded case the issue describes.

@mfw78

mfw78 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

GitHub closed this on its own and refuses to reopen it: the head repository it was raised from is private and detached from the fork network, so the pull request is not recoverable.

Continued in #5542, raised from a public fork with the same branch and commits. @gacevicljubisa your question is answered there.

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.

pseudosettle: first-contact allowance anchored to Unix epoch instead of connection time

2 participants