From 8555e29990aeacac4d30e0dc92d72e2634315840 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 8 Jul 2026 12:12:55 +0000 Subject: [PATCH] fix(pseudosettle): bound first-contact allowance to connection time 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. --- pkg/settlement/pseudosettle/pseudosettle.go | 17 +++--- .../pseudosettle/pseudosettle_test.go | 53 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/pkg/settlement/pseudosettle/pseudosettle.go b/pkg/settlement/pseudosettle/pseudosettle.go index 24531b84641..e0f56bb3430 100644 --- a/pkg/settlement/pseudosettle/pseudosettle.go +++ b/pkg/settlement/pseudosettle/pseudosettle.go @@ -60,8 +60,9 @@ type Service struct { } type pseudoSettlePeer struct { - lock sync.Mutex // lock to be held during receiving a payment from this peer - fullNode bool + lock sync.Mutex // lock to be held during receiving a payment from this peer + fullNode bool + connectedAt int64 // unix time the peer connected; anchors the first-contact allowance } type lastPayment struct { @@ -108,7 +109,7 @@ func (s *Service) init(ctx context.Context, p p2p.Peer) error { _, ok := s.peers[p.Address.String()] if !ok { - peerData := &pseudoSettlePeer{fullNode: p.FullNode} + peerData := &pseudoSettlePeer{fullNode: p.FullNode, connectedAt: s.timeNow().Unix()} s.peers[p.Address.String()] = peerData } @@ -142,14 +143,18 @@ func totalKeyPeer(key []byte, prefix string) (peer swarm.Address, err error) { // peerAllowance computes the maximum incoming payment value we accept // this is the time based allowance or the peers actual debt, whichever is less -func (s *Service) peerAllowance(peer swarm.Address, fullNode bool) (limit *big.Int, stamp int64, err error) { +func (s *Service) peerAllowance(peer swarm.Address, fullNode bool, connectedAt int64) (limit *big.Int, stamp int64, err error) { var lastTime lastPayment err = s.store.Get(totalKey(peer, SettlementReceivedPrefix), &lastTime) if err != nil { if !errors.Is(err, storage.ErrNotFound) { return nil, 0, err } - lastTime.Timestamp = int64(0) + // First contact with this peer: anchor the time-based allowance to the + // connection time rather than the zero epoch. Anchoring at zero would + // scale the entire Unix epoch by the refresh rate and let the initial + // refreshment forgive an effectively unbounded debt. + lastTime.Timestamp = connectedAt } currentTime := s.timeNow().Unix() @@ -210,7 +215,7 @@ func (s *Service) handler(ctx context.Context, p p2p.Peer, stream p2p.Stream) (e pseudoSettlePeer.lock.Lock() defer pseudoSettlePeer.lock.Unlock() - allowance, timestamp, err := s.peerAllowance(p.Address, pseudoSettlePeer.fullNode) + allowance, timestamp, err := s.peerAllowance(p.Address, pseudoSettlePeer.fullNode, pseudoSettlePeer.connectedAt) if err != nil { return err } diff --git a/pkg/settlement/pseudosettle/pseudosettle_test.go b/pkg/settlement/pseudosettle/pseudosettle_test.go index c1b1b32f3c2..82c63ac4c0b 100644 --- a/pkg/settlement/pseudosettle/pseudosettle_test.go +++ b/pkg/settlement/pseudosettle/pseudosettle_test.go @@ -277,6 +277,9 @@ func TestPayment(t *testing.T) { receiverObserver := newTestObserver(map[string]*big.Int{peerID.String(): big.NewInt(debt)}, map[string]*big.Int{}) recipient := pseudosettle.New(nil, logger, storeRecipient, receiverObserver, big.NewInt(testRefreshRate), big.NewInt(testRefreshRateLight), mockp2p.New()) recipient.SetAccounting(receiverObserver) + // Establish a connection time before init so the first-contact allowance is + // anchored to it rather than to the Unix epoch. + recipient.SetTime(1) err := recipient.Init(context.Background(), peer) if err != nil { t.Fatal(err) @@ -295,6 +298,50 @@ func TestPayment(t *testing.T) { testCaseAccepted(t, recorder, payerObserver, receiverObserver, payer, recipient, peerID, 30, 30, 1, 1, 1, big.NewInt(debt), big.NewInt(debt), big.NewInt(debt), big.NewInt(debt)) } +// TestFirstContactAllowanceBounded verifies that the time-based allowance granted +// on first contact is bounded by the time elapsed since the peer connected, not by +// the Unix epoch. A peer connected one second ago may only settle a single refresh +// interval of debt, no matter how large its outstanding debt is. Anchoring at the +// epoch would let the first refreshment forgive an effectively unbounded amount. +func TestFirstContactAllowanceBounded(t *testing.T) { + t.Parallel() + + logger := log.Noop + + storeRecipient := newStateStore(t) + + peerID := swarm.MustParseHexAddress("9ee7add7") + peer := p2p.Peer{Address: peerID, FullNode: true} + + // Debt far larger than a single refresh interval's worth of allowance. + debt := int64(1000) * testRefreshRate + + payerObserver := newTestObserver(map[string]*big.Int{peerID.String(): big.NewInt(debt)}, map[string]*big.Int{}) + receiverObserver := newTestObserver(map[string]*big.Int{peerID.String(): big.NewInt(debt)}, map[string]*big.Int{}) + recipient := pseudosettle.New(nil, logger, storeRecipient, receiverObserver, big.NewInt(testRefreshRate), big.NewInt(testRefreshRateLight), mockp2p.New()) + recipient.SetAccounting(receiverObserver) + // The peer connects at t=1000000. + recipient.SetTime(1000000) + err := recipient.Init(context.Background(), peer) + if err != nil { + t.Fatal(err) + } + + recorder := streamtest.New( + streamtest.WithProtocols(recipient.Protocol()), + streamtest.WithBaseAddr(peerID), + ) + + storePayer := newStateStore(t) + + payer := pseudosettle.New(recorder, logger, storePayer, payerObserver, big.NewInt(testRefreshRate), big.NewInt(testRefreshRateLight), mockp2p.New()) + payer.SetAccounting(payerObserver) + + // One second after connecting the peer attempts to settle its entire debt. + // Only one refresh interval (testRefreshRate) may be accepted, not the full debt. + testCaseAccepted(t, recorder, payerObserver, receiverObserver, payer, recipient, peerID, 1000001, 1000001, 1, 1, 1, big.NewInt(debt), big.NewInt(debt), big.NewInt(testRefreshRate), big.NewInt(testRefreshRate)) +} + func TestTimeLimitedPayment(t *testing.T) { t.Parallel() @@ -311,6 +358,9 @@ func TestTimeLimitedPayment(t *testing.T) { receiverObserver := newTestObserver(map[string]*big.Int{peerID.String(): big.NewInt(debt)}, map[string]*big.Int{}) recipient := pseudosettle.New(nil, logger, storeRecipient, receiverObserver, big.NewInt(testRefreshRate), big.NewInt(testRefreshRateLight), mockp2p.New()) recipient.SetAccounting(receiverObserver) + // Establish a connection time before init so the first-contact allowance is + // anchored to it rather than to the Unix epoch. + recipient.SetTime(1) err := recipient.Init(context.Background(), peer) if err != nil { t.Fatal(err) @@ -365,6 +415,9 @@ func TestTimeLimitedPaymentLight(t *testing.T) { receiverObserver := newTestObserver(map[string]*big.Int{peerID.String(): big.NewInt(debt)}, map[string]*big.Int{}) recipient := pseudosettle.New(nil, logger, storeRecipient, receiverObserver, big.NewInt(testRefreshRate), big.NewInt(testRefreshRateLight), mockp2p.New()) recipient.SetAccounting(receiverObserver) + // Establish a connection time before init so the first-contact allowance is + // anchored to it rather than to the Unix epoch. + recipient.SetTime(1) err := recipient.Init(context.Background(), peer) if err != nil { t.Fatal(err)