Skip to content
Merged
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
9 changes: 0 additions & 9 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,6 @@ Getting **testnet to a two-person shared lab** is also a standing plan, not a pi

----

- Align `foldReimbursements` donation rounding with the contract’s per-share
accumulator (`accumulatedReimbursementPerClaimShare` / `mulDiv`). The fold
currently splits each donation with per-holder `claim * amount / outstanding`
integer division, then subtracts the full donation from `outstanding`, so UI
forgo/withdrawable caps can disagree with on-chain views by leftover wei.
Mirror the contract (scaled accumulator, or live view reads) and add a
remainder-aware test with two holders and a donation that does not divide
evenly. Found in review of `feature/combinator-operand-nonblocking-load`.

- **(Tell)** Refresh `data/seed-implication-evaluations.original-variants.json`
against the current implication-attester prompt fingerprint. The prompt now
rejects nested-place geographic rollup (Grey County → Ontario is a worked
Expand Down
4 changes: 3 additions & 1 deletion fake-data-generation/campaignProvisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ const GAS_UNITS: Record<PlannedAction['type'], bigint> = {
};

const DEFAULT_GAS_PRICE = 1_000_000_000n;
const NATIVE_BUFFER_WEI = parseEther('0.05');
// Slack on top of the per-action gas estimate, not a spending allowance.
// 0.05 ETH per wallet made a 10-user canary look like it needed ~1 ETH.
const NATIVE_BUFFER_WEI = parseEther('0.001');


export const PAYMENT_TOKEN_FUNDING_ABI = [
Expand Down
4 changes: 2 additions & 2 deletions fake-data-generation/test/campaignProvisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ function localEnv(): CampaignEnvironment {
test('funding needs include gas, note deposits, and project token buys', () => {
const [need] = computeCampaignFundingNeeds(plan, [wallet], 1_000_000_000n);
assert.equal(need.walletSlot, 'wallet-user-001');
assert.ok(need.nativeWei > parseEther('0.05'));
assert.ok(need.nativeWei > parseEther('0.001'));
assert.equal(need.paymentTokenUnits, parseUnits('0.01', 6));
assert.ok(need.nativeWei >= parseEther('0.05') + 90_000n * 1_000_000_000n + 180_000n * 1_000_000_000n + 150_000n * 1_000_000_000n + parseEther('0.01'));
assert.ok(need.nativeWei >= parseEther('0.001') + 90_000n * 1_000_000_000n + 180_000n * 1_000_000_000n + 150_000n * 1_000_000_000n + parseEther('0.01'));
});

test('local provisioning mints when transfer fails and skips already-funded wallets', async () => {
Expand Down
56 changes: 56 additions & 0 deletions sdk/src/subsystems/lazy-giving/folds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,62 @@ describe('foldReimbursements', () => {
forgoneAmount: '0',
});
});

it('keeps indivisible donation dust on the share accumulator, matching the contract views', () => {
const common = {
contractAddress: PROJECT_ADDR,
blockNumber: 110n,
blockTimestamp: 1700001000n,
transactionHash: TX_HASH_5,
logIndex: 0,
};
const result = foldReimbursements(PROJECT_ADDR, [
{ type: 'bought', event: makeBoughtEvent({ participant: PARTICIPANT_A, totalCost: 2n }) },
{ type: 'bought', event: makeBoughtEvent({ participant: PARTICIPANT_B, totalCost: 1n }) },
{ type: 'retroactiveDonation', event: { ...common, donor: RECIPIENT, amount: 1n } },
]);

// Outstanding falls by the full 1, but 1 wei does not divide across 3 shares.
// Per-holder currency division would leave claims of 2 and 1; the contract
// views are mulDiv(shares, outstanding, supply) and a still-zero accumulator payout.
assert.strictEqual(result.project.outstandingReimbursement, '2');
assert.strictEqual(result.project.totalRetroactiveDonations, '1');
const holderA = result.contributors.find(({ contributor }) => contributor === PARTICIPANT_A);
const holderB = result.contributors.find(({ contributor }) => contributor === PARTICIPANT_B);
assert.strictEqual(holderA?.futureReimbursementClaim, '1');
assert.strictEqual(holderB?.futureReimbursementClaim, '0');
assert.strictEqual(holderA?.reimbursableAmount, '0');
assert.strictEqual(holderB?.reimbursableAmount, '0');
});

it('assigns withdrawable wei from share balance after a ceiling mint, not from currency claims', () => {
const donation = {
contractAddress: PROJECT_ADDR,
blockNumber: 110n,
blockTimestamp: 1700001000n,
transactionHash: TX_HASH_5,
logIndex: 0,
};
const result = foldReimbursements(PROJECT_ADDR, [
{ type: 'bought', event: makeBoughtEvent({ participant: PARTICIPANT_A, totalCost: 3n }) },
{ type: 'retroactiveDonation', event: { ...donation, amount: 1n } },
{ type: 'bought', event: makeBoughtEvent({ participant: PARTICIPANT_B, totalCost: 1n, transactionHash: TX_HASH_2 }) },
{ type: 'retroactiveDonation', event: { ...donation, amount: 2n, transactionHash: TX_HASH_3 } },
]);

// B pays 1 against outstanding 2 and supply 3, so the contract mints ceil(1*3/2) = 2 shares.
// Accumulator payouts are 2 and 0; currency-claim splitting would pay A only 1 and
// leave both holders a future claim of 1.
assert.strictEqual(result.project.totalEarlyContributions, '4');
assert.strictEqual(result.project.outstandingReimbursement, '1');
const holderA = result.contributors.find(({ contributor }) => contributor === PARTICIPANT_A);
const holderB = result.contributors.find(({ contributor }) => contributor === PARTICIPANT_B);
assert.strictEqual(holderA?.reimbursableAmount, '2');
assert.strictEqual(holderB?.reimbursableAmount, '0');
assert.strictEqual(holderA?.futureReimbursementClaim, '0');
assert.strictEqual(holderB?.futureReimbursementClaim, '0');
assert.strictEqual(holderB?.earlyContribution, '1');
});
});

// ============================================================================
Expand Down
221 changes: 153 additions & 68 deletions sdk/src/subsystems/lazy-giving/folds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,108 +248,193 @@ export function foldContributions(
return foldContributionsFromEvents(boughtEvents, soldEvents, undefined, fundingCurrency);
}

/** Fold contribution and waterfall events into project and per-contributor reimbursement state. */
/**
* Matches `REIMBURSEMENT_PER_SHARE_SCALE` in AssuranceContracts.sol.
* Donations accrue in this scale so leftover wei stays assigned to shares
* until a later donation pushes a holder across a whole token unit.
*/
const REIMBURSEMENT_PER_SHARE_SCALE = 10n ** 36n;

function mulDivFloor(x: bigint, y: bigint, denominator: bigint): bigint {
return (x * y) / denominator;
}

/** OpenZeppelin Math.mulDiv(..., Rounding.Ceil). */
function mulDivCeil(x: bigint, y: bigint, denominator: bigint): bigint {
const product = x * y;
const rounded = product / denominator;
return product % denominator === 0n ? rounded : rounded + 1n;
}

/**
* Fold contribution and waterfall events into project and per-contributor reimbursement state.
*
* Replays the contract's claim-share ledger: purchases mint shares with a
* ceiling division, retroactive donations bump `accumulatedReimbursementPerClaimShare`,
* and each holder's withdrawable and future claim are the contract's `mulDiv` views.
* A same-transaction buy plus forgo that exceeds the claim (donate-normally, which
* never mints) undoes the shares just minted. A sell applies the refund's basis
* reduction; the matching `ReimbursementForgone` event, when present, is what
* counts toward forgone totals.
*/
export function foldReimbursements(
projectAddress: string,
events: ReimbursementEvent[],
fundingCurrency: Currency = ETH_CURRENCY,
): { project: ProjectReimbursementState; contributors: ContributorReimbursementState[] } {
const contributions = new Map<string, bigint>();
const futureClaims = new Map<string, bigint>();
const withdrawable = new Map<string, bigint>();
const early = new Map<string, bigint>();
const shares = new Map<string, bigint>();
const checkpoint = new Map<string, bigint>();
const storedWithdrawable = new Map<string, bigint>();
const withdrawn = new Map<string, bigint>();
const forgone = new Map<string, bigint>();
let totalRetroactiveDonations = 0n;
let outstanding = 0n;
const seen = new Set<string>();
const lastMint = new Map<string, { tx: string; value: bigint; shares: bigint }>();
let totalEarly = 0n;
let totalRetro = 0n;
let totalShares = 0n;
let accumulated = 0n;

const keyOf = (address: string) => address.toLowerCase();
const outstanding = () => totalEarly - totalRetro;
const balanceOf = (key: string) => shares.get(key) ?? 0n;

const add = (map: Map<string, bigint>, address: string, amount: bigint) => {
const key = address.toLowerCase();
map.set(key, (map.get(key) ?? 0n) + amount);
const futureClaim = (key: string): bigint => {
if (totalShares === 0n) return 0n;
return mulDivFloor(balanceOf(key), outstanding(), totalShares);
};
const subtractContributionClamped = (address: string, amount: bigint) => {
const key = address.toLowerCase();
const tracked = contributions.get(key) ?? 0n;
contributions.set(key, tracked > amount ? tracked - amount : 0n);

const checkpointAccount = (key: string) => {
const marked = checkpoint.get(key) ?? 0n;
if (accumulated !== marked) {
const earned = mulDivFloor(balanceOf(key), accumulated - marked, REIMBURSEMENT_PER_SHARE_SCALE);
storedWithdrawable.set(key, (storedWithdrawable.get(key) ?? 0n) + earned);
checkpoint.set(key, accumulated);
}
};
const subtractClamped = (map: Map<string, bigint>, address: string, amount: bigint) => {
const key = address.toLowerCase();
const tracked = map.get(key) ?? 0n;
const reduction = tracked < amount ? tracked : amount;
map.set(key, tracked - reduction);
return reduction;

const withdrawableOf = (key: string): bigint => {
const settled = storedWithdrawable.get(key) ?? 0n;
const marked = checkpoint.get(key) ?? 0n;
if (accumulated === marked) return settled;
return settled + mulDivFloor(balanceOf(key), accumulated - marked, REIMBURSEMENT_PER_SHARE_SCALE);
};

const mintShares = (key: string, value: bigint, tx: string) => {
checkpointAccount(key);
const out = outstanding();
const minted = totalShares === 0n || out === 0n
? value
: mulDivCeil(value, totalShares, out);
shares.set(key, balanceOf(key) + minted);
totalShares += minted;
early.set(key, (early.get(key) ?? 0n) + value);
totalEarly += value;
lastMint.set(key, { tx, value, shares: minted });
};

const undoMint = (key: string, value: bigint, minted: bigint) => {
const held = balanceOf(key);
const burned = minted < held ? minted : held;
shares.set(key, held - burned);
totalShares -= burned;
const tracked = early.get(key) ?? 0n;
const basis = value < tracked ? value : tracked;
early.set(key, tracked - basis);
totalEarly -= basis;
};

const reduceBasis = (key: string, amount: bigint, countForgone: boolean, tx?: string) => {
if (amount === 0n) return;
checkpointAccount(key);
const claim = futureClaim(key);
const tracked = early.get(key) ?? 0n;
const phantom = tx === undefined ? undefined : lastMint.get(key);
if (phantom && phantom.tx === tx && phantom.value === amount && amount > claim) {
undoMint(key, amount, phantom.shares);
if (countForgone) forgone.set(key, (forgone.get(key) ?? 0n) + amount);
return;
}
const applied = amount < claim && amount < tracked ? amount : (claim < tracked ? claim : tracked);
if (applied === 0n) return;
const held = balanceOf(key);
const out = outstanding();
let burned = applied === claim
? held
: (out === 0n ? 0n : mulDivCeil(applied, totalShares, out));
if (burned > held) burned = held;
shares.set(key, held - burned);
totalShares -= burned;
early.set(key, tracked - applied);
totalEarly -= applied;
if (countForgone) forgone.set(key, (forgone.get(key) ?? 0n) + applied);
};

for (const { type, event } of events) {
switch (type) {
case 'bought':
add(contributions, event.participant, event.totalCost);
add(futureClaims, event.participant, event.totalCost);
outstanding += event.totalCost;
case 'bought': {
const key = keyOf(event.participant);
seen.add(key);
mintShares(key, event.totalCost, event.transactionHash);
break;
// Match recordPrimaryRefund: the reimbursement basis may already have
// been reduced by a forgo, while the full token value is still refunded.
}
// recordPrimaryRefund forgoes the tracked basis (clamped to what is left)
// before ERC1155Sold. When that Forgone event is in the stream it has
// already reduced the basis, and this is a no-op.
case 'sold': {
subtractContributionClamped(event.participant, event.totalCost);
const reduction = subtractClamped(futureClaims, event.participant, event.totalCost);
outstanding -= reduction;
const key = keyOf(event.participant);
seen.add(key);
const tracked = early.get(key) ?? 0n;
const reduction = event.totalCost < tracked ? event.totalCost : tracked;
reduceBasis(key, reduction, false);
break;
}
case 'retroactiveDonation': {
const before = outstanding;
if (before > 0n) {
for (const [contributor, claim] of futureClaims) {
const earned = claim * event.amount / before;
futureClaims.set(contributor, claim - earned);
add(withdrawable, contributor, earned);
}
if (totalShares > 0n && event.amount > 0n) {
accumulated += mulDivFloor(event.amount, REIMBURSEMENT_PER_SHARE_SCALE, totalShares);
}
outstanding -= event.amount;
totalRetroactiveDonations += event.amount;
totalRetro += event.amount;
break;
}
case 'reimbursementWithdrawn':
subtractClamped(withdrawable, event.contributor, event.amount);
add(withdrawn, event.contributor, event.amount);
case 'reimbursementWithdrawn': {
const key = keyOf(event.contributor);
seen.add(key);
checkpointAccount(key);
const available = storedWithdrawable.get(key) ?? 0n;
const paid = event.amount < available ? event.amount : available;
storedWithdrawable.set(key, available - paid);
withdrawn.set(key, (withdrawn.get(key) ?? 0n) + paid);
break;
case 'reimbursementForgone':
add(contributions, event.contributor, -event.amount);
subtractClamped(futureClaims, event.contributor, event.amount);
outstanding -= event.amount;
add(forgone, event.contributor, event.amount);
}
case 'reimbursementForgone': {
const key = keyOf(event.contributor);
seen.add(key);
reduceBasis(key, event.amount, true, event.transactionHash);
break;
}
}
}

const totalEarlyContributions = [...contributions.values()].reduce((sum, value) => sum + value, 0n);
const totalWithdrawn = [...withdrawn.values()].reduce((sum, value) => sum + value, 0n);
const totalForgone = [...forgone.values()].reduce((sum, value) => sum + value, 0n);
const addresses = new Set([
...contributions.keys(), ...futureClaims.keys(), ...withdrawable.keys(),
...withdrawn.keys(), ...forgone.keys(),
]);
const contributors = [...addresses].map((contributor) => {
const contribution = contributions.get(contributor) ?? 0n;
const contributorWithdrawn = withdrawn.get(contributor) ?? 0n;
const reimbursable = withdrawable.get(contributor) ?? 0n;
return {
projectAddress,
contributor,
currency: fundingCurrency,
earlyContribution: contribution.toString(),
futureReimbursementClaim: (futureClaims.get(contributor) ?? 0n).toString(),
reimbursableAmount: reimbursable.toString(),
withdrawnAmount: contributorWithdrawn.toString(),
forgoneAmount: (forgone.get(contributor) ?? 0n).toString(),
};
});
const contributors = [...seen].map((contributor) => ({
projectAddress,
contributor,
currency: fundingCurrency,
earlyContribution: (early.get(contributor) ?? 0n).toString(),
futureReimbursementClaim: futureClaim(contributor).toString(),
reimbursableAmount: withdrawableOf(contributor).toString(),
withdrawnAmount: (withdrawn.get(contributor) ?? 0n).toString(),
forgoneAmount: (forgone.get(contributor) ?? 0n).toString(),
}));

return {
project: {
projectAddress,
currency: fundingCurrency,
totalEarlyContributions: totalEarlyContributions.toString(),
totalRetroactiveDonations: totalRetroactiveDonations.toString(),
outstandingReimbursement: outstanding.toString(),
totalEarlyContributions: totalEarly.toString(),
totalRetroactiveDonations: totalRetro.toString(),
outstandingReimbursement: outstanding().toString(),
totalReimbursementsWithdrawn: totalWithdrawn.toString(),
totalReimbursementsForgone: totalForgone.toString(),
},
Expand Down
Loading
Loading