Skip to content

Exact-sum subscriber attribution via largest-remainder (audit C1)#136

Open
brawlaphant wants to merge 1 commit into
regen-network:mainfrom
brawlaphant:fix/pool-attribution-bigint-math
Open

Exact-sum subscriber attribution via largest-remainder (audit C1)#136
brawlaphant wants to merge 1 commit into
regen-network:mainfrom
brawlaphant:fix/pool-attribution-bigint-math

Conversation

@brawlaphant
Copy link
Copy Markdown
Contributor

Closes the pool.ts:388 attribution portion of audit C1-HIGH.

Problem

recordAttributions() was:

const fraction = sub.amount_cents / totalRevenueCents;  // integer div coerced to float
updateAttribution(db, attr.id, {
  carbon_credits: carbon.creditsRetired * fraction,     // float × float
  biodiversity_credits: biodiversity.creditsRetired * fraction,
  uss_credits: uss.creditsRetired * fraction,
});

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 to total.
  • creditsToMicro / microToCredits — single narrow boundary conversions; one float touch each, bounded error of 0.5 micro per conversion.

recordAttributions now:

const weights = subscribers.map((s) => BigInt(s.amount_cents));
const carbonShares = allocateProportional(creditsToMicro(carbon.creditsRetired), weights);
// ...same for biodiversity, uss
for (let i = 0; i < subscribers.length; i++) {
  updateAttribution(db, attr.id, {
    carbon_credits: microToCredits(carbonShares[i]),
    // ...
  });
}

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:

it(\"splits 1.0 credit between equal subscribers exactly (no float drift)\", () => {
  const total = creditsToMicro(1); // 1_000_000n
  const shares = allocateProportional(total, [1n, 1n, 1n]);
  expect(shares.reduce((a, b) => a + b, 0n)).toBe(total);
  // Two get 333_333n, one bumped to 333_334n by largest-remainder.
});

With the old float code, 3 * (1/3) lost 1 micro to representation error. The new code is exact.

The existing pool.test.ts integration 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 — clean
  • npm test — 64/64 passing
  • npm run build — clean
  • Reviewer: confirm tie-breaking-by-index is acceptable for determinism. Alternative: tie-break by subscriber ID. With weights derived from amount_cents, true ties are rare.
  • Reviewer: schema is unchanged (REAL columns). If you want exact-precision storage too, a follow-up migration adding *_micro INTEGER columns would close that gap. Out of scope here.

Out of scope (further audit C1 follow-ups)

Refs: AUDIT.md C1-HIGH (pool.ts:388 portion)

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/services/pool.ts
Comment on lines +558 to +561
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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);

Comment thread src/services/pool.ts
Comment on lines +564 to +565
for (let i = 0; i < subscribers.length; i++) {
const sub = subscribers[i];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the sorted subscribers array to ensure the shares match the correct subscribers.

Suggested change
for (let i = 0; i < subscribers.length; i++) {
const sub = subscribers[i];
for (let i = 0; i < sortedSubscribers.length; i++) {
const sub = sortedSubscribers[i];

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant