diff --git a/TODO.md b/TODO.md index d217b362..1588e8eb 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/fake-data-generation/campaignProvisioning.ts b/fake-data-generation/campaignProvisioning.ts index 63a9c34f..8640ed72 100644 --- a/fake-data-generation/campaignProvisioning.ts +++ b/fake-data-generation/campaignProvisioning.ts @@ -13,7 +13,9 @@ const GAS_UNITS: Record = { }; 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 = [ diff --git a/fake-data-generation/test/campaignProvisioning.test.ts b/fake-data-generation/test/campaignProvisioning.test.ts index 6a5f6cc6..68beffc8 100644 --- a/fake-data-generation/test/campaignProvisioning.test.ts +++ b/fake-data-generation/test/campaignProvisioning.test.ts @@ -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 () => { diff --git a/sdk/src/subsystems/lazy-giving/folds.test.ts b/sdk/src/subsystems/lazy-giving/folds.test.ts index 0c000a29..99c3de07 100644 --- a/sdk/src/subsystems/lazy-giving/folds.test.ts +++ b/sdk/src/subsystems/lazy-giving/folds.test.ts @@ -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'); + }); }); // ============================================================================ diff --git a/sdk/src/subsystems/lazy-giving/folds.ts b/sdk/src/subsystems/lazy-giving/folds.ts index 6eff1163..3fac54a0 100644 --- a/sdk/src/subsystems/lazy-giving/folds.ts +++ b/sdk/src/subsystems/lazy-giving/folds.ts @@ -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(); - const futureClaims = new Map(); - const withdrawable = new Map(); + const early = new Map(); + const shares = new Map(); + const checkpoint = new Map(); + const storedWithdrawable = new Map(); const withdrawn = new Map(); const forgone = new Map(); - let totalRetroactiveDonations = 0n; - let outstanding = 0n; + const seen = new Set(); + const lastMint = new Map(); + 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, 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, 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(), }, diff --git a/workflow/branching.md b/workflow/branching.md index 93cccee7..f3324865 100644 --- a/workflow/branching.md +++ b/workflow/branching.md @@ -12,7 +12,10 @@ git push -u origin feature/the-thing # 3. push the branch (no gate to push) gh pr create --base dev --fill # 4. open the PR # 5. review + post the receipt: /code-review --comment && scripts/post-review.sh gh pr merge --auto --merge # 6. queue merge; do NOT wait for GitHub Actions -# 7. start the next task (see "Don't wait on CI" below) +# 7. once it has landed: switch back to local dev and delete the local branch +git switch dev && git pull +git branch -d feature/the-thing +# 8. start the next task (see "Don't wait on CI" below) ``` - **Never work on `dev` directly.** If you forget and try to commit, the hook @@ -27,6 +30,12 @@ gh pr merge --auto --merge # 6. queue merge; do NOT wait for GitHub A are informational. They are **not** required to merge. If Adam says "merge when it's ready," that means review receipt + resolved threads, not a six-minute CI wait. +- **After a PR lands, leave `dev` checked out and delete the local branch.** + Adam does not keep merged feature branches around. Switch back to local + `dev`, pull, and `git branch -d` the branch that just merged. Do this for + the branch you just merged; do not sweep unrelated local branches unless + asked. GitHub deletes the remote branch on merge; the local one is yours + to remove. ## Overview @@ -79,8 +88,13 @@ this step. gh pr merge --auto --merge # or --squash; your call ``` Auto-merge lands the PR as soon as `review-received` is green, threads are - resolved, and the branch is up to date with `dev` (`strict` is on). Then - delete the branch when GitHub does (or after it lands). + resolved, and the branch is up to date with `dev` (`strict` is on). GitHub + deletes the remote branch. Once the PR has actually merged, switch the + local checkout back to `dev`, pull, and delete the local branch + (`git switch dev && git pull && git branch -d feature/your-thing`). Do not + leave that checkout sitting on the merged branch, and do not keep the + local branch "just in case." If auto-merge has only been queued, stay on + the feature branch until it lands, then do the switch and delete. 6. **Release:** fast-forward `master` to `dev`: ```bash scripts/promote-dev-to-master.sh