Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/__tests__/attribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import {
allocateProportional,
creditsToMicro,
microToCredits,
ATTRIBUTION_SCALE,
} from "../services/attribution.js";

describe("creditsToMicro / microToCredits", () => {
it("round-trips integer credit counts exactly", () => {
for (const n of [0, 1, 100, 1000.5]) {
expect(microToCredits(creditsToMicro(n))).toBe(n);
}
});

it("rounds half to nearest micro", () => {
expect(creditsToMicro(0.0000005)).toBe(1n);
expect(creditsToMicro(0.0000004)).toBe(0n);
});

it("clamps non-finite or negative input to 0", () => {
expect(creditsToMicro(NaN)).toBe(0n);
expect(creditsToMicro(-1)).toBe(0n);
expect(creditsToMicro(Infinity)).toBe(0n);
});

it("ATTRIBUTION_SCALE is 1_000_000n", () => {
expect(ATTRIBUTION_SCALE).toBe(1_000_000n);
});
});

describe("allocateProportional (audit C1)", () => {
it("returns zero shares when total is zero", () => {
expect(allocateProportional(0n, [1n, 2n, 3n])).toEqual([0n, 0n, 0n]);
});

it("returns zero shares when all weights are zero", () => {
expect(allocateProportional(100n, [0n, 0n, 0n])).toEqual([0n, 0n, 0n]);
});

it("returns empty array for empty weights", () => {
expect(allocateProportional(100n, [])).toEqual([]);
});

it("rejects negative weights", () => {
expect(() => allocateProportional(100n, [-1n, 2n])).toThrow();
});

it("splits 1.0 credit between equal subscribers exactly (no float drift)", () => {
// Previous float code: 1/3 ≈ 0.333..., 3 * 0.333 = 0.999, 1 micro lost.
const total = creditsToMicro(1); // 1_000_000n
const shares = allocateProportional(total, [1n, 1n, 1n]);
const sum = shares.reduce((a, b) => a + b, 0n);
expect(sum).toBe(total);
// Each share is 333_333n, with one bumped to 333_334n by largest-remainder.
expect(shares.filter((s) => s === 333_333n).length).toBe(2);
expect(shares.filter((s) => s === 333_334n).length).toBe(1);
});

it("the leftover micro goes to the largest-residual share, deterministically", () => {
// weights [1, 2] with total 1n: floor shares are [0, 0], remainders are
// [1, 2] (out of weightSum=3). Index 1 has the larger residual.
expect(allocateProportional(1n, [1n, 2n])).toEqual([0n, 1n]);
});

it("weights determine proportion: 1:5 split of 6n exactly", () => {
expect(allocateProportional(6n, [1n, 5n])).toEqual([1n, 5n]);
});

it("scales correctly to 1.5M credits across 1000 weighted subscribers", () => {
const weights: bigint[] = [];
for (let i = 0; i < 1000; i++) weights.push(BigInt(100 + (i % 50)));
const total = creditsToMicro(1_500_000);
const shares = allocateProportional(total, weights);
expect(shares).toHaveLength(1000);
const sum = shares.reduce((a, b) => a + b, 0n);
expect(sum).toBe(total);
});

it("REGRESSION: subscriber A=$2, B=$10 gives B exactly 5x A's share", () => {
const total = creditsToMicro(0.6); // 600_000n
const shares = allocateProportional(total, [200n, 1000n]);
expect(shares[0] + shares[1]).toBe(total);
expect(shares[1]).toBe(shares[0] * 5n);
});

it("ties on remainder break by index (lower index gets the +1)", () => {
// weights [1, 1] with total 1n: floor = [0, 0], remainders both 1.
// Tie broken by idx → idx 0 gets the leftover micro.
expect(allocateProportional(1n, [1n, 1n])).toEqual([1n, 0n]);
});

it("never allocates more than total even with extreme weights", () => {
const total = creditsToMicro(1);
// One huge weight + many tiny — naïve float would round and over-allocate.
const weights = [10_000_000n, ...Array(100).fill(1n)];
const shares = allocateProportional(total, weights);
expect(shares.reduce((a, b) => a + b, 0n)).toBe(total);
});
});
81 changes: 81 additions & 0 deletions src/services/attribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Proportional attribution helpers.
*
* The pool run distributes a fixed total of credits across many subscribers
* weighted by each subscriber's contribution. Doing this with float
* arithmetic (audit C1) means the per-subscriber shares may not sum to
* exactly the retired total — small drift, but compounds across many runs
* and undermines the on-chain invariant that retirements are exact.
*
* `allocateProportional` uses the largest-remainder method (Hamilton):
* 1. floor share for each weight: floor(total * weight / sumWeights)
* 2. distribute the remainder, one micro-unit at a time, to the weights
* with the largest fractional residual (ties broken by index)
* The returned bigint shares are guaranteed to sum to exactly `total`.
*/

/** Conversion scale used to ladder a JS-number credit count into bigint micro-credits. */
export const ATTRIBUTION_SCALE = 1_000_000n;

/**
* Convert a JS number credit count (e.g. 1.5) to bigint micro-credits.
* The single float touch — bounded error of 0.5 micro per conversion.
*/
export function creditsToMicro(credits: number): bigint {
if (!Number.isFinite(credits) || credits < 0) return 0n;
return BigInt(Math.round(credits * Number(ATTRIBUTION_SCALE)));
}

/** Convert bigint micro-credits back to a JS number for REAL-column storage. */
export function microToCredits(micro: bigint): number {
return Number(micro) / Number(ATTRIBUTION_SCALE);
}

/**
* Distribute `total` units across N buckets weighted by `weights`, returning
* an array of bigint shares whose sum is exactly `total`.
*
* Returns an array of zeros if total === 0n or all weights sum to 0 — caller
* should treat that as "nothing to allocate."
*/
export function allocateProportional(total: bigint, weights: readonly bigint[]): bigint[] {
if (weights.length === 0 || total <= 0n) {
return weights.map(() => 0n);
}
let weightSum = 0n;
for (const w of weights) {
if (w < 0n) throw new Error("Negative weight in allocateProportional");
weightSum += w;
}
if (weightSum === 0n) {
return weights.map(() => 0n);
}

// Floor shares + fractional residuals.
const shares: bigint[] = new Array(weights.length).fill(0n);
const residuals: { idx: number; remainder: bigint }[] = [];
let allocated = 0n;
for (let i = 0; i < weights.length; i++) {
const numer = total * weights[i];
const floor = numer / weightSum;
const remainder = numer - floor * weightSum;
shares[i] = floor;
allocated += floor;
residuals.push({ idx: i, remainder });
}

// Distribute remainder to largest residuals; stable on idx for determinism.
let leftover = total - allocated;
if (leftover > 0n) {
residuals.sort((a, b) => {
if (a.remainder === b.remainder) return a.idx - b.idx;
return a.remainder < b.remainder ? 1 : -1;
});
for (let i = 0; i < residuals.length && leftover > 0n; i++) {
shares[residuals[i].idx] += 1n;
leftover -= 1n;
}
}

return shares;
}
23 changes: 17 additions & 6 deletions src/services/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "../server/db.js";
import { sendMonthlyEmails } from "./email.js";
import { executeBurn, type BurnResult, formatBurnResult } from "./burn.js";
import { allocateProportional, creditsToMicro, microToCredits } from "./attribution.js";

export interface PoolRunResult {
poolRunId: number;
Expand Down Expand Up @@ -547,16 +548,26 @@ function recordAttributions(
biodiversity: CreditTypeResult,
uss: CreditTypeResult
): void {
if (totalRevenueCents <= 0) return;
if (totalRevenueCents <= 0 || subscribers.length === 0) return;

// Audit C1: previously this used `sub.amount_cents / totalRevenueCents` then
// multiplied each retired total by that float fraction. Per-subscriber shares
// could drift such that their sum was not exactly the retired total. Now
// computed in bigint micro-credits via Hamilton's largest-remainder method,
// which guarantees the shares sum exactly to creditsToMicro(creditsRetired).
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);
Comment on lines +558 to +561

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


const txn = db.transaction(() => {
for (const sub of subscribers) {
const fraction = sub.amount_cents / totalRevenueCents;
for (let i = 0; i < subscribers.length; i++) {
const sub = subscribers[i];
Comment on lines +564 to +565

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];

const attr = createAttribution(db, poolRunId, sub.id, sub.amount_cents);
updateAttribution(db, attr.id, {
carbon_credits: carbon.creditsRetired * fraction,
biodiversity_credits: biodiversity.creditsRetired * fraction,
uss_credits: uss.creditsRetired * fraction,
carbon_credits: microToCredits(carbonShares[i]),
biodiversity_credits: microToCredits(biodiversityShares[i]),
uss_credits: microToCredits(ussShares[i]),
});
}
});
Expand Down
Loading