Exact-sum subscriber attribution via largest-remainder (audit C1)#136
Exact-sum subscriber attribution via largest-remainder (audit C1)#136brawlaphant wants to merge 1 commit into
Conversation
Closes the pool.ts:388 portion of audit C1-HIGH (attribution).
Previously recordAttributions() did:
const fraction = sub.amount_cents / totalRevenueCents;
updateAttribution(db, attr.id, {
carbon_credits: carbon.creditsRetired * fraction,
biodiversity_credits: biodiversity.creditsRetired * fraction,
uss_credits: uss.creditsRetired * fraction,
});
`amount_cents / totalRevenueCents` is integer division coerced to a
float, then multiplied by another float. The audit flagged that
"per-subscriber attribution fractions may not sum to exactly 1.0" —
small drift, but undermines the on-chain invariant that the sum of
attributions equals what was actually retired.
This replaces the float computation with bigint largest-remainder
(Hamilton's method) in a new helper:
src/services/attribution.ts
- allocateProportional(total: bigint, weights: bigint[]): bigint[]
Returns shares whose sum is GUARANTEED to equal `total`.
- creditsToMicro / microToCredits — narrow conversion helpers
with a single, bounded float touch at each boundary.
The DB schema still uses REAL columns for storage (changing them is
a separate migration concern), but every individual stored value is
now derived from an exact bigint share, so the SUM-of-attributions
invariant is restored.
Tests: 15 new vitest cases covering:
- creditsToMicro / microToCredits round-trip + half-rounding
- allocateProportional empty / zero-total / zero-weights edges
- REGRESSION: 1.0 credit across 3 equal subs sums to exactly 1.0
(with the old float code this would have lost 1 micro)
- ratio preservation for unequal weights ($2 vs $10 → 5×)
- tie-breaking by index for determinism
- 1000-subscriber stress test sums exactly
- pathological weight distribution (10M : 1 × 100) sums exactly
Existing pool.test.ts integration test ("records per-subscriber
fractional attributions") still passes — the 5× ratio between $10
and $2 subscribers is preserved.
Full suite: 64/64 passing (49 prior + 15 new).
Out of scope (further audit C1 follow-ups):
- pool.ts:217 / pool.ts:337-352 greedy-fill float math
(mirrors order-selector.ts; same fix pattern as PR regen-network#135)
- evm-wallet.ts:110 amount computation
Refs: AUDIT.md C1-HIGH (pool.ts:388 attribution portion)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new proportional attribution system using the largest-remainder method (Hamilton) with BigInt arithmetic to ensure that credit distributions sum exactly to the total retired, eliminating floating-point drift. A new attribution.ts service and corresponding tests were added, and the recordAttributions function in pool.ts was updated to utilize this logic. The review feedback correctly identifies a potential non-determinism issue where tie-breaking depends on the order of the subscribers array; it is recommended to sort subscribers by a unique identifier before processing to ensure consistent results across runs.
| const weights = subscribers.map((s) => BigInt(s.amount_cents)); | ||
| const carbonShares = allocateProportional(creditsToMicro(carbon.creditsRetired), weights); | ||
| const biodiversityShares = allocateProportional(creditsToMicro(biodiversity.creditsRetired), weights); | ||
| const ussShares = allocateProportional(creditsToMicro(uss.creditsRetired), weights); |
There was a problem hiding this comment.
The attribution logic is currently non-deterministic when multiple subscribers have equal fractional remainders (e.g., when they have the same amount_cents). Because allocateProportional uses the array index to break ties and the subscribers array is populated from a database query without an explicit ORDER BY clause, the extra micro-credits may be assigned to different subscribers across different runs. Sorting the subscribers by a unique ID ensures that tie-breaking is consistent and deterministic, addressing the concern raised in the PR description.
| const weights = subscribers.map((s) => BigInt(s.amount_cents)); | |
| const carbonShares = allocateProportional(creditsToMicro(carbon.creditsRetired), weights); | |
| const biodiversityShares = allocateProportional(creditsToMicro(biodiversity.creditsRetired), weights); | |
| const ussShares = allocateProportional(creditsToMicro(uss.creditsRetired), weights); | |
| const sortedSubscribers = [...subscribers].sort((a, b) => a.id - b.id); | |
| const weights = sortedSubscribers.map((s) => BigInt(s.amount_cents)); | |
| const carbonShares = allocateProportional(creditsToMicro(carbon.creditsRetired), weights); | |
| const biodiversityShares = allocateProportional(creditsToMicro(biodiversity.creditsRetired), weights); | |
| const ussShares = allocateProportional(creditsToMicro(uss.creditsRetired), weights); |
| for (let i = 0; i < subscribers.length; i++) { | ||
| const sub = subscribers[i]; |
There was a problem hiding this comment.
Closes the
pool.ts:388attribution portion of audit C1-HIGH.Problem
recordAttributions()was:The audit flagged: "per-subscriber attribution fractions may not sum to exactly 1.0."
Small drift, but it breaks the invariant subscribers see on their dashboard: "sum of everyone's attributions = the credits we actually retired." Compounds across pool runs.
What this PR does
New helper
src/services/attribution.ts:allocateProportional(total: bigint, weights: bigint[]): bigint[]— Hamilton's largest-remainder method. Floor each share, distribute the remainder one micro at a time to the largest fractional residuals. Sum of returned shares is GUARANTEED equal tototal.creditsToMicro/microToCredits— single narrow boundary conversions; one float touch each, bounded error of 0.5 micro per conversion.recordAttributionsnow:The DB schema still uses REAL columns — changing those is a separate migration concern. But each individual stored value is now derived from an exact bigint share, so the sum invariant is restored.
Tests
15 new cases. Highlight regression test:
With the old float code,
3 * (1/3)lost 1 micro to representation error. The new code is exact.The existing
pool.test.tsintegration test (5× ratio between $10 and $2 subscribers) still passes unchanged.Full suite: 64/64 passing (49 prior + 15 new).
Test plan
npm run typecheck— cleannpm test— 64/64 passingnpm run build— cleanamount_cents, true ties are rare.*_micro INTEGERcolumns would close that gap. Out of scope here.Out of scope (further audit C1 follow-ups)
pool.ts:217/pool.ts:337-352greedy-fill float math — mirrorsorder-selector.ts, same fix pattern as BigInt micro-credit math in order-selector (audit C1) #135evm-wallet.ts:110amount computationRefs:
AUDIT.mdC1-HIGH (pool.ts:388 portion)🤖 Generated with Claude Code