Tally Reputation

Bitswap-inspired peer reputation and rate limiting. Tracks exchange history, computes composite reputation scores, and gates requests using rate pressure. Naturally Sybil-resistant — new peers must earn credit.

.package(url: "https://github.com/treehauslabs/Tally.git", from: "1.0.0")

Tally

The main entry point. Thread-safe via internal locking — all methods are nonisolated and safe to call from any context.

public struct Tally: Sendable {
    public init(config: TallyConfig = .default)
}

Recording Methods

Call these when exchanging data with peers. They update the peer's ledger and mark reputation as stale.

MethodDescription
recordSent(peer:bytes:cpl:) Track bytes sent to peer. Optional CPL for distance scaling.
recordReceived(peer:bytes:cpl:) Track bytes received from peer. Builds their credit.
recordRequest(peer:) Increment request counter for peer.
recordSuccess(peer:) Record a successful response from peer.
recordFailure(peer:) Record a failed response from peer.
recordLatency(peer:microseconds:) Record a latency sample. Updates EWMA.
Distance scaling (CPL)
When a Common Prefix Length is provided, bytes are scaled by (256 - cpl) / 256. Serving data to nearby peers (high CPL) costs less reputation than serving distant peers. This incentivizes nodes to serve their local neighborhood.

Gating

shouldAllow(peer:) → Bool

The primary gating function. Determines whether to serve a request from a peer based on current rate pressure and the peer's reputation.

if tally.shouldAllow(peer: requestingPeer) {
    // serve the request
} else {
    // send dontHave
}

Query Methods

MethodReturnDescription
reputation(for:)DoubleComposite score 0–1. Triggers decay.
debtRatio(for:)DoublebytesSent / (bytesReceived + 1)
peerLedger(for:)PeerLedger?Full ledger for a peer
allPeers()[PeerID]All tracked peer IDs
resetPeer(_:)VoidRemove a peer's ledger entirely
ratePressure()DoubleCurrent rate pressure 0–2
peerCountIntNumber of tracked peers
metricsTallyMetricsAggregate allow/deny/byte counts

PeerLedger

Per-peer accounting. Tracks every dimension of the relationship with a single peer.

public struct PeerLedger: Sendable {
    public var bytesSent: DecayingCounter
    public var bytesReceived: DecayingCounter
    public var requestCount: Int
    public var successCount: Int
    public var failureCount: Int
    public var challengeHardness: Int
    public var firstSeen: ContinuousClock.Instant
    public var lastSeen: ContinuousClock.Instant
    public var latencyEWMA: EWMA
}

Computed Properties

PropertyFormula
debtRatiobytesSent / (bytesReceived + 1)
reciprocity1.0 / (1.0 + debtRatio)
successRatesuccesses / (successes + failures) or 0.5 if no data
failurePenalty(failures / total)² — quadratic penalty

DecayingCounter

A counter whose value decays exponentially over time:

value *= exp2(-elapsedSeconds / halfLife)

This means recent exchanges matter more than historical ones. A peer who was generous a week ago but hasn't contributed since will see their credit decay.

EWMA (Exponential Weighted Moving Average)

Used for latency tracking. Smoothing factor α = 0.3 by default:

value = α × sample + (1 - α) × value

Also tracks min, max, and count.

Reputation Scoring

Reputation is a composite score in the range [0, 1], computed from four weighted components:

ComponentWeightWhat It Measures
Reciprocity0.2Balance of bytes exchanged. Freeloaders score low.
Latency0.3Response speed relative to baseline. Fast peers score high.
Success Rate0.4Fraction of successful responses minus quadratic failure penalty.
Challenges0.1Cumulative proof-of-work difficulty solved.

Formula

reputation = weights.reciprocity × adjustedReciprocity
           + weights.latency × latencyScore
           + weights.successRate × (successRate - failurePenalty)
           + weights.challenges × challengeRatio

Where:

New peers
A brand-new peer with no exchange history gets a reputation near 0.5 (from the default successRate). Under low rate pressure, they'll be served. Under high pressure, they'll need to build credit first.

Custom Weights

let config = TallyConfig(
    weights: ReputationWeights(
        reciprocity: 0.3,
        latency: 0.2,
        successRate: 0.4,
        challenges: 0.1
    )
)
let tally = Tally(config: config)

Rate Pressure

Rate pressure determines how strict gating should be. It's based on actual throughput vs the configured limit:

pressure = bytesInWindow / (rateLimitBytesPerSecond × windowDuration)

Clamped to [0, 2]. The shouldAllow() thresholds scale with pressure:

Pressure RangeBehavior
< 0.5Always allow (plenty of capacity)
0.5 – 1.0Require reputation ≥ scaled threshold (0.0 → 0.8)
≥ 1.0Require reputation ≥ 0.8 (only well-known peers)
Self-balancing
No manual rate limit tuning needed. Under low load, everyone gets served. Under high load, the system automatically prioritizes peers who contribute the most. Freeloaders are the first to be throttled.

Proof-of-Work Challenges

Peers can earn reputation by solving computational puzzles. This provides a bootstrap mechanism for new peers who haven't exchanged data yet.

// Node issues a challenge
let challenge = tally.issueChallenge()

// Peer solves it
let solver = ChallengeSolver()
let solution = solver.solve(challenge)

// Node verifies and credits peer
let valid = tally.verifyChallenge(challenge, solution: solution, peer: peer)
// peer.challengeHardness += difficulty

Challenge Struct

public struct Challenge: Sendable {
    public let nonce: Data          // 32 random bytes
    public let difficulty: Int      // required leading zero bits
    public let expiresAfter: Duration
    public var isExpired: Bool
    public func verify(solution: Data) -> Bool
}

Verification: SHA-256(nonce + solution) must have ≥ difficulty leading zero bits. Default difficulty is 16 bits (~65k hash attempts). Challenges expire after 30 seconds.

TallyConfig

public struct TallyConfig: Sendable {
    public let weights: ReputationWeights       // default: .default
    public let latencyBaseline: Double        // default: 100,000 µs
    public let decayHalfLife: Double          // default: 3600 s
    public let challengeDifficulty: Int       // default: 16 bits
    public let challengeExpiration: Duration  // default: 30 s
    public let rateLimitBytesPerSecond: Double // default: 10 MB/s
    public let rateWindow: Double            // default: 1.0 s
    public let hardnessBaseline: Int         // default: 160
    public let exchangeBaseline: Double      // default: 100,000
    public let maxPeers: Int?                // default: nil
}

TallyMetrics

public struct TallyMetrics: Sendable, Equatable {
    public var allowed: Int
    public var denied: Int
    public var totalBytesSent: Int
    public var totalBytesReceived: Int
    public var challengesIssued: Int
    public var challengesVerified: Int
}