Skip to main content

SsaReconstructor

Struct SsaReconstructor 

Source
pub struct SsaReconstructor<S: PixSpec> {
    commitment_builder: Cache<SsaId<S::Pseudonym>, Arc<Mutex<SsaCommitmentBuilder<S>>>>,
    ssa_cycles: Cache<SsaId<S::Pseudonym>, Arc<SsaCycle<S>>>,
    awaiting_acks: Cache<OffchainPublicKey, Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S, <S as PixSpec>::Pseudonym, PixScalar<S>>>>,
    pending_acks: Cache<SsaId<S::Pseudonym>, Arc<Mutex<DeferredAcks>>>,
    ready_resolutions: Mutex<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>>,
    ready_resolutions_len: AtomicUsize,
    retired_ssas: Cache<SsaId<S::Pseudonym>, ()>,
    ack_buffer_entries: Arc<AtomicUsize>,
    max_ack_buffer_entries: usize,
    ack_buffer_resync: Mutex<Option<Instant>>,
    cfg: SsaReconstructorConfig,
}
Expand description

Allows server-side reconstruction of SSAs.

There are 3 inputs that reconstructor is dependent on (in order):

  1. SSA commitments from the Client (delivered via insert_coefficient_commitments)
  2. Extraction of pending encrypted shares (added via insert_encrypted_share
  3. Decryption of pending encrypted shares via [Acknowledgement]s (via acknowledge_shares)

It is able to track SSA for multiple different pseudonyms (Sessions).

Fields§

§commitment_builder: Cache<SsaId<S::Pseudonym>, Arc<Mutex<SsaCommitmentBuilder<S>>>>§ssa_cycles: Cache<SsaId<S::Pseudonym>, Arc<SsaCycle<S>>>

Post-commitment state of every live cycle: the part accumulator and all part builders, published and reclaimed as one unit. See SsaCycle.

§awaiting_acks: Cache<OffchainPublicKey, Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S, <S as PixSpec>::Pseudonym, PixScalar<S>>>>§pending_acks: Cache<SsaId<S::Pseudonym>, Arc<Mutex<DeferredAcks>>>

Acknowledgements that arrived before their cycle’s part builders were installed, bucketed by cycle and then by polynomial.

§Why bucketed at all

The bucket key is exactly the thing whose arrival unblocks the entries inside it, so a bucket is drained once, by the installation of its own cycle, and never scanned speculatively. That is what keeps Self::acknowledge_shares free of retry work: it only ever appends to a bucket.

The original per-peer stash had to be re-scanned in full on every acknowledge_shares call, because a per-peer key says nothing about which entries have become viable. That is quadratic in the number of acks received while a cycle’s commitments are in flight, and the per-peer key aggregates across every Session sharing a first-relayer.

§Why keyed by cycle, sub-bucketed by polynomial

Part builders are installed for a whole cycle at once, so a per-polynomial key no longer buys selective draining — the drain would just walk every polynomial of the cycle. Keying by cycle makes it one lookup. The per-polynomial sub-bucket is kept because its cap is what bounds a misbehaving peer (see MAX_DEFERRED_ACKS_PER_POLYNOMIAL).

The capacity unit is cycles, not polynomials. Keyed per polynomial it was 2 * MAX_POLYS_PER_SSA entries — which one cycle can exhaust on its own, so past roughly four concurrent cycles node-wide, moka began LRU-evicting buckets and silently dropping real shares. A size eviction here is share loss, so the headroom is deliberate and the max_ack_await_time TTL is the operative bound.

§ready_resolutions: Mutex<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>>

Resolutions produced by draining deferred-ack buckets at verifier-installation time, waiting to be picked up by the next Self::acknowledge_shares call.

Draining happens on the commitment path (insert_coefficient_commitments), which is where the verifier that unblocks the acks is installed. That deliberately keeps the share verification off the acknowledgement hot path, but it also means the resolutions surface somewhere that has no route to the upper layer — hence this hand-off. Acks flow continuously while a Session is live, so pickup latency is one ack batch.

§ready_resolutions_len: AtomicUsize

Length of ready_resolutions, so the common case (nothing to pick up) costs one relaxed load instead of a mutex acquisition on every ack batch.

§retired_ssas: Cache<SsaId<S::Pseudonym>, ()>

Tombstone set: SsaIds that have been retired. The commitment completion path checks this after publishing the cycle, preventing resurrection when retire_ssa runs concurrently.

§ack_buffer_entries: Arc<AtomicUsize>

Running estimate of the entries live in awaiting_acks, summed over every peer, so the global budget costs one relaxed load per insertion.

An estimate, and knowingly so — see Self::resync_ack_buffer for the one drift source that cannot be listened for and what keeps it from accumulating.

Behind an Arc because each peer’s inner cache decrements it from an eviction listener, and moka requires those to be 'static — they cannot borrow the reconstructor that owns them.

§max_ack_buffer_entries: usize

max_ack_buffer_bytes in entries, divided once here rather than on every insertion.

§ack_buffer_resync: Mutex<Option<Instant>>

When resync_ack_buffer last ran, and the lock that keeps two from running at once.

None until the first run, so a buffer that saturates immediately is not made to wait out an interval before its first ground-truth reading.

§cfg: SsaReconstructorConfig

Implementations§

Source§

impl<S: PixSpec + Clone> SsaReconstructor<S>

Source

pub fn try_new( cfg: SsaReconstructorConfig, ) -> Result<Self, PixError<S::Pseudonym>>

Creates a new SSA reconstructor from the given configuration.

Fails if the configuration does not validate. Prefer this over Self::new anywhere the configuration is assembled at runtime — a config built programmatically or read from a file is input, not a constant, and turning it into a panic makes it un-handleable by the caller.

Source

pub fn new(cfg: SsaReconstructorConfig) -> Self

Creates a new SSA reconstructor from the given configuration.

§Panics

Panics if the configuration fails validation. Use Self::try_new to handle that case instead.

Source

pub fn config(&self) -> &SsaReconstructorConfig

Returns the configuration of the reconstructor.

Source

pub fn contains_builder(&self, ssa_id: &SsaId<S::Pseudonym>) -> bool

Returns true if the reconstructor still holds a builder (SSA-part builder or commitment builder) for the given cycle. Used by tests to verify that retire_ssa cleaned up the expected state.

Source

fn remove_cycle(&self, ssa_id: SsaId<S::Pseudonym>)

Removes all reconstructor state for a single SSA cycle.

Idempotent: invalidating an absent key is a no-op.

Source

fn process_verified_ack( &self, ack: HalfKey, ack_challenge: HalfKeyChallenge, awaiting_ack_from_peer: &Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S>>, ) -> Result<ProcessedAckResult<S>, PixError<S::Pseudonym>>

Source

fn defer_ack( &self, spi: SsaPolynomialId<S::Pseudonym>, deferred: (OffchainPublicKey, HalfKeyChallenge, HalfKey), )

Buckets an acknowledgement whose cycle’s part builders have not been installed yet.

O(1) — this is the entire cost the acknowledgement path pays for a deferral.

Source

fn defer_ack_into( &self, bucket: &Arc<Mutex<DeferredAcks>>, spi: SsaPolynomialId<S::Pseudonym>, deferred: (OffchainPublicKey, HalfKeyChallenge, HalfKey), )

The bucket half of defer_ack, taking the bucket rather than looking it up.

Split out so a test can hold a handle across the drain that invalidates the cache key, which is the interleaving this guards against and the one thing a single thread cannot otherwise produce — after the invalidate, get_with hands out a fresh bucket.

Source

fn drain_deferred_acks(&self, ssa_id: &SsaId<S::Pseudonym>)

Redeems the acknowledgements that were waiting for this cycle’s part builders.

Called from the commitment path immediately after the cycle is installed, so each bucket is processed exactly once and never speculatively re-scanned. Resolutions are parked in ready_resolutions for the next acknowledge_shares call, since the commitment path has no route to the upper layer.

Source

fn redeem_deferred_acks( &self, ssa_id: &SsaId<S::Pseudonym>, deferred: impl IntoIterator<Item = (OffchainPublicKey, HalfKeyChallenge, HalfKey)>, )

Processes acknowledgements whose verifier has since been installed, parking whatever they resolve to.

Shared by the two routes that can redeem a deferral — the drain on the commitment path and an orphaned append — so both produce the same resolutions in the same order.

Source

fn take_ready_resolutions( &self, ) -> Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>

Takes any resolutions parked by drain_deferred_acks.

One relaxed load in the common case — the buckets are empty whenever the Entry finishes the constant-term pass before the shares that reference it arrive.

Source

fn ack_buffer_resync_interval(&self) -> Duration

Minimum wall time between two resync_ack_buffer passes.

A saturated buffer would otherwise turn every rejected insertion into an O(max_tracked_peers) scan — the overload path amplifying its own cost, which is the shape of bug this budget exists to prevent.

Derived from max_ack_await_time rather than fixed, because what a resync reclaims is entries that have aged out of that window. A constant would be wrong at both ends: too slow for a short window, so a drained buffer keeps refusing shares long after it emptied, and needlessly eager for a long one.

The resulting staleness — up to ~1.9 s at the 30 s default — only bites when the counter has drifted high and nothing is touching the caches, since redemption and any cache access drive moka’s expiry maintenance and fire the listener directly. That is the traffic-stopped case, where refusing a share costs nothing.

Source

fn resync_ack_buffer(&self)

Recomputes ack_buffer_entries from what the caches actually hold.

§Why a counter needs a backstop at all

Entries leave the buffer four ways: redeemed by their acknowledgement, expired, size-evicted from their peer’s cache, or dropped wholesale when the peer’s entry leaves awaiting_acks. The inner eviction listener catches the first three exactly. The fourth cannot be caught: dropping a moka handle does not run its eviction listener, so the outer listener falls back to invalidate_all, which is best-effort and races an insertion landing on the very cache being discarded.

Left alone, that residue only ever accumulates upward, and an over-count is far worse than an under-count: it would eventually refuse every share while the buffer sat empty, turning a memory ceiling into a permanent outage of the acknowledgement path. (The sibling HoprUnacknowledgedTicketProcessor in hopr-protocol-hopr has the same nesting and the same residue; there it only skews metrics.)

So the counter is treated as a hint that is allowed to be wrong, and ground truth is consulted at the one moment being wrong would cost something — when it says the buffer is full. try_lock rather than lock: a caller that finds a resync already running should proceed on the current estimate, not queue up behind it.

Source

fn count_ack_buffer_entries(&self) -> usize

Ground truth: the entries actually held across every peer.

Never reads ack_buffer_entries, so a test asserting the two agree is testing the counter rather than agreeing with it — the same reason deferred_ack_count recomputes from by_poly instead of reading DeferredAcks::total.

O(max_tracked_peers), and each run_pending_tasks is bounded by that cache’s pending write queue rather than its size. Both callers keep it off the steady-state path.

Source§

impl<S: PixSpec + Clone> SsaReconstructor<S>

Source

fn release_abandoned_commitment(&self, ssa_id: SsaId<S::Pseudonym>)

Releases a commitment that was registered but never taken over by an owner.

Deliberately not retire_ssa, which additionally writes the resurrection tombstone. The tombstone is permanent for that SsaId for as long as it is retained, and it takes effect at the moment a cycle is published — so a retry at the same index would re-register, accept the peer’s commitments, publish a deposit address, and then be silently undone at completion. The peer funds an SSA that can never be reconstructed, and nothing on either side reports a failure.

Same-index retry is not a corner case: the SSA index is advanced only after every fallible step of a request has succeeded, so a request that failed keeps its index by design and the next attempt reuses it.

Escalates to a full retirement if a cycle did go live, which means the peer was asked and answered — and therefore that ownership should already have been transferred with disarm. That branch is a caller error, and retiring is the safe response to it, because a live cycle is exactly what the tombstone exists to protect.

Source

pub fn new_guarded_exit_commitment( self: &Arc<Self>, id: SsaId<S::Pseudonym>, polys_per_ssa: usize, shares_per_poly: usize, ) -> Result<(PixGroup<S>, SsaCommitmentGuard<S>), PixError<S::Pseudonym>>

new_exit_commitment, with the registration owned by an SsaCommitmentGuard.

No guard is produced on failure, so a rejected duplicate never retires the registration that caused the rejection.

Trait Implementations§

Source§

impl<S: PixSpec + Clone> Default for SsaReconstructor<S>

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<S: PixSpec> Drop for SsaReconstructor<S>

Source§

fn drop(&mut self)

Reports terminal resolutions that were never collected.

ready_resolutions is a hand-off the commitment path fills and only acknowledge_shares empties, so delivery waits on the next acknowledgement batch from any peer. That is the common case and not the guaranteed one: a Session whose final cycle recovers through the deferred-ack drain, and which then stops sending because the cycle it was funding is complete, leaves the last resolution sitting here.

Retirement is not the deadline — a retired cycle’s resolution stays collectable, since the buffer is global and its entries name their own SsaId. This is, and nothing here can deliver: the commitment path has no route to the upper layer, which is why these were parked rather than returned. So the most that can be done is to refuse to lose them quietly. A RecoveredSsa reported here is a deposit key the Exit held and never handed on.

The real fix is for the reconstructor to push rather than be pulled, which needs a sink on its constructor; that is bundled with threading a real SsaReconstructorConfig through the three sites in hopr-transport that hard-code ::default().

Source§

fn pin_drop(self: Pin<&mut Self>)

🔬This is a nightly-only experimental API. (pin_ergonomics)
Execute the destructor for this type, but different to Drop::drop, it requires self to be pinned. Read more
Source§

impl<S: PixSpec + Clone> ExitAcknowledgementShareProcessor<S> for SsaReconstructor<S>

Source§

fn insert_encrypted_share( &self, peer: &OffchainPublicKey, challenge: HalfKeyChallenge, tagged_enc_share: TaggedEncryptedPartialSsaShare<S>, ) -> Result<(), Self::Error>

Buffers an encrypted share until its acknowledgement arrives, subject to the global byte budget.

§The bound is global, and it is not the product of the two caps

max_tracked_peers and max_awaiting_acks bound one dimension each, and their product — 2 000 × 1 000 000 by default, some 800 GB — is neither reachable nor the right thing to bound. Not reachable, because an entry exists only for a share this node has already sent, so filling it would take 66 M packets/s of egress inside the default 30 s window. Not right, because the two guard mutually exclusive concentrations: max_awaiting_acks sizes one cache per peer and has to cover every Session returning through a single first-relayer, while max_tracked_peers covers traffic spread thin. Squeezing the product would push one of them below what its own case needs, and a max_awaiting_acks set too low does not save memory — it size-evicts shares before their acknowledgements arrive.

So the real bound is max_ack_buffer_bytes, counted here across all peers at once. Validating a workload model instead would not do: a model has to assume a Session count and a packet rate, and this node enforces neither.

§Behaviour at the ceiling

The newest share is refused, rather than the oldest evicted: there is no cheap global “oldest” across per-peer caches, and the oldest is nearest its TTL anyway. Either way a full buffer means share loss — the packet is already on the wire and its acknowledgement will find nothing — which is the honest cost of a hard ceiling. PixError::AckBufferFull is deliberately not an expected error so the caller logs it.

The check and the insertion are not atomic. Concurrent inserters can overshoot the ceiling by their own number, which is the right trade: a lock on this path would cost more than the few hundred kilobytes of overshoot it would prevent.

Source§

type Error = PixError<<S as PixSpec>::Pseudonym>

Source§

fn has_pending_shares(&self, peer: &OffchainPublicKey) -> bool

Returns true if the peer has pending encrypted shares awaiting an acknowledgement. Read more
Source§

fn is_expected_error(&self, error: &Self::Error) -> bool

Returns true if the given error is an expected “not for us” skip (e.g. no acknowledgements from the peer were expected), so the caller can log it at a lower severity. Read more
Source§

fn retire_ssa(&self, ssa_id: SsaId<S::Pseudonym>)

Releases all reconstructor state for a finished or torn-down SSA cycle. Read more
Source§

fn new_exit_commitment( &self, id: SsaId<S::Pseudonym>, polys_per_ssa: usize, shares_per_poly: usize, ) -> Result<PixGroup<S>, Self::Error>

Generates a new random Exit SSA commitment and registers it internally under the given id.
Source§

fn insert_coefficient_commitments( &self, ssa_id: SsaId<S::Pseudonym>, index: CoefficientIndex, proof: Option<SsaCommitmentProof<S>>, commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<S>)>, ) -> Result<SsaCommitmentState<S::Pseudonym, S::DepositAddress>, Self::Error>

Adds the client commitment data. Read more
Source§

fn acknowledge_shares( &self, peer: OffchainPublicKey, acks: Vec<Acknowledgement>, ) -> Result<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>, Self::Error>

Finds and acknowledges previously inserted encrypted share, using incoming [Acknowledgement]s from the upstream peer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<T> FutureExt for T

§

fn with_context(self, otel_cx: Context) -> WithContext<Self>

Attaches the provided Context to this type, returning a WithContext wrapper. Read more
§

fn with_current_context(self) -> WithContext<Self>

Attaches the current Context to this type, returning a WithContext wrapper. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
§

impl<T> Pointable for T

§

const ALIGN: usize

The alignment of pointer.
§

type Init = T

The type for initializers.
§

unsafe fn init(init: <T as Pointable>::Init) -> usize

Initializes a with the given initializer. Read more
§

unsafe fn deref<'a>(ptr: usize) -> &'a T

Dereferences the given pointer. Read more
§

unsafe fn deref_mut<'a>(ptr: usize) -> &'a mut T

Mutably dereferences the given pointer. Read more
§

unsafe fn drop(ptr: usize)

Drops the object pointed to by the given pointer. Read more
§

impl<T> PolicyExt for T
where T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns Action::Follow. Read more
§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a [WithDispatch] wrapper. Read more