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.
| Method | Description |
|---|---|
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. |
(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
| Method | Return | Description |
|---|---|---|
reputation(for:) | Double | Composite score 0–1. Triggers decay. |
debtRatio(for:) | Double | bytesSent / (bytesReceived + 1) |
peerLedger(for:) | PeerLedger? | Full ledger for a peer |
allPeers() | [PeerID] | All tracked peer IDs |
resetPeer(_:) | Void | Remove a peer's ledger entirely |
ratePressure() | Double | Current rate pressure 0–2 |
peerCount | Int | Number of tracked peers |
metrics | TallyMetrics | Aggregate 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
| Property | Formula |
|---|---|
debtRatio | bytesSent / (bytesReceived + 1) |
reciprocity | 1.0 / (1.0 + debtRatio) |
successRate | successes / (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:
| Component | Weight | What It Measures |
|---|---|---|
| Reciprocity | 0.2 | Balance of bytes exchanged. Freeloaders score low. |
| Latency | 0.3 | Response speed relative to baseline. Fast peers score high. |
| Success Rate | 0.4 | Fraction of successful responses minus quadratic failure penalty. |
| Challenges | 0.1 | Cumulative proof-of-work difficulty solved. |
Formula
reputation = weights.reciprocity × adjustedReciprocity
+ weights.latency × latencyScore
+ weights.successRate × (successRate - failurePenalty)
+ weights.challenges × challengeRatio
Where:
- adjustedReciprocity = reciprocity × confidence (confidence rises with total bytes exchanged vs baseline)
- latencyScore = min(baseline / (ewma + 1), 1.0)
- challengeRatio = min(challengeHardness / hardnessBaseline, 1.0)
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 Range | Behavior |
|---|---|
< 0.5 | Always allow (plenty of capacity) |
0.5 – 1.0 | Require reputation ≥ scaled threshold (0.0 → 0.8) |
≥ 1.0 | Require reputation ≥ 0.8 (only well-known peers) |
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
}