diff --git a/docs/wiki/progress.md b/docs/wiki/progress.md index 930c614c4..bcf347054 100644 --- a/docs/wiki/progress.md +++ b/docs/wiki/progress.md @@ -1,6 +1,6 @@ --- title: Progress -updated: 2026-07-31 +updated: 2026-08-04 type: ledger --- @@ -10,6 +10,7 @@ Stage ledger. One row per stage; keep entries short. Verifier = exact fresh comm | Stage | Status | Verifier | Review | Next | |---|---|---|---|---| +| deploy fee distribution bps precision (shared `utils/fees` helpers) | human-review-required (base 76c047530) | gate green: typecheck + lint + 864 unit (11 new) · 72 helper · smoke 59 · new `general/deploy/index-dtf-revenue` desktop+mobile+full · propose-dtf-settings 3/3 · RED-verified: integer fee → `33 %`; float even-split → `33.33` not `33.34` | correctness+security+product+complexity: self, per-claim — bps floor never exceeds the pot, remainder still lands on the last recipient (exactly 1e18) | **Engineer review required** (on-chain fee math): BSC fee `1/3` → 33.33% everywhere (fallback too); deploy + propose share `revenuePortionFromShare`/`splitSharesEvenly` | | vlRSR self-appreciating vaults: drawer shares/redeem + rate line · governance card · portfolio · earn rate-corrected + APY | human-review-required (base d0427a7cf; SDK local-linked) | gate green: typecheck+lint+847 unit · 72 helper · smoke 58 · drawer spec 2/2 · live BSC visual light+dark incl. earn TVL 261.5M RSR = totalAssets | Dark + Light on both diffs; all blockers fixed; details in git | PR #1072 open on SDK 0.5.1 (published, pinned exact; direct sdk dep dropped). Companions: dtf-interface#29, reserve-api#236 (deploy w/ daos CDN purge). **Engineer review**: withdraw→redeem for ALL vaults, governance card, api token.price×rate. [plan](../plans/vlrsr-self-appreciating-vaults.md) | | focused-tab automatic chain switching | human-review-required (base b2fcf72c6) | lint/typecheck · unit 840 incl. 8 focus/visibility · helpers 70 · smoke 56 + 1 skipped · wiki-lint | Dark + Light: stale Index target + mutation-boundary focus recheck fixed; automatic switching preserved, background tabs passive | Engineer review wallet/chain flow; then ship | | vote-lock APR unified on /dtf/daos list | done (base 771c92873) | gate-equivalent green (lint/typecheck/unit, 70 helper, 56 smoke, wiki-lint) · live visual: BUILDOUT + POWER overviews and earn all 46.83% from one list request | Dark + Light, per-claim verify — adopted: list-miss/error fallback gating, plain-data return, catalog mixed-case normalization; API re-key REVERTED by Luis (sdk `getVoteLockDao` needs the DTF-address key); accepted: unlisted DTFs keep per-DTF detail cache split | pre-existing reserve-api debt flagged, not shipped: maxAge-before-await caches 500s 24h; unguarded `underlyingPrice.price` deref can 500 whole list | diff --git a/e2e/TEST_MAP.md b/e2e/TEST_MAP.md index 6bb5fe61d..32e4f4662 100644 --- a/e2e/TEST_MAP.md +++ b/e2e/TEST_MAP.md @@ -31,7 +31,7 @@ the directory). | Explorer | [general/explorer/render](tests/general/explorer/render.spec.ts) | transactions tab (default) render; governance tab proposals render; one chain returning malformed transactions body doesn't blank the page | none | no | filters, pagination, tokens/collaterals/revenue tabs | | Portfolio | [general/portfolio/state-space](tests/general/portfolio/state-space.spec.ts), [general/portfolio/partial-response](tests/general/portfolio/partial-response.spec.ts) | disconnected shows connect prompt; malformed proposal row survives, healthy row still renders | none | partial (state-space only) | connected-with-holdings render, empty-vs-past-activity-only | | Tokens | [general/tokens/unlisted-partial](tests/general/tokens/unlisted-partial.spec.ts) | one chain returning an rtokens-less bucket doesn't crash the table | none | no | plain listed-table render, sort | -| Create (Index/Yield deploy) | — | — | — | — | entirely uncovered | +| Create (Index/Yield deploy) | [general/deploy/index-dtf-revenue](tests/general/deploy/index-dtf-revenue.spec.ts) | Index deploy Fees & Distribution: fractional (1/3) platform fee shown at 0.01% precision; 0.01%-grid shares close the allocation; even distribution allocates the whole non-platform pot | none | yes (precision spec) | every other wizard step (basket, governance, roles, confirm/deploy tx) and the Yield deploy wizard uncovered | ## Index DTF (`/:chain/index-dtf/:tokenId/*`) @@ -86,7 +86,9 @@ additional state coverage: [boot](tests/smoke/boot.spec.ts) (home shell), - Legacy v2 auctions UI and bid writes: no specs. - Yield DTF governance, auctions, and settings/roles: no specs at all (no `yield-dtf/governance|auctions|settings` dirs exist). -- Create Index DTF / Create Yield DTF deploy wizards: no specs. +- Create Index DTF: only the Fees & Distribution step's precision behavior is + covered (`general/deploy/index-dtf-revenue`); basket, governance, roles, and + the confirm/deploy transactions have no specs. Create Yield DTF: no specs. - Explorer: only 3 of the tab surfaces render-tested (transactions, governance, malformed-body edge); tokens/collaterals/revenue tabs, filters, and pagination are untested. diff --git a/e2e/tests/general/deploy/index-dtf-revenue.spec.ts b/e2e/tests/general/deploy/index-dtf-revenue.spec.ts new file mode 100644 index 000000000..54b96f062 --- /dev/null +++ b/e2e/tests/general/deploy/index-dtf-revenue.spec.ts @@ -0,0 +1,88 @@ +import { + encodeAbiParameters, + encodeFunctionData, + parseAbi, + zeroAddress, + type Address, +} from 'viem' +import { test, expect, type DtfHarness } from '../../../harness' + +// Index DTF deploy wizard — Fees & Distribution precision. The DAO fee registry +// can take a fractional cut of revenue (BSC takes 1/3 → 33.33%); the wizard must +// show and split it on the same 0.01% grid its share inputs accept, or the shares +// can never total 100% and every recipient portion drifts. +test.use({ wallet: false }) + +const INDEX_DEPLOYER = '0x4D201a6e5BF975E2CEE9e5cbDfc803C0Ff122073' // mainnet — the form's default chain +const FEE_REGISTRY = '0x1234567890123456789012345678901234567890' as Address +const DAO_FEE_REGISTRY = '0x9980cb23' // daoFeeRegistry() +const FEE_DETAILS_ABI = parseAbi([ + 'function getFeeDetails(address rToken) view returns (address recipient, uint256 feeNumerator, uint256 feeDenominator, uint256 feeFloor)', +]) + +// No folio exists yet, so the wizard reads the deployer's registry with the zero +// address. numerator/denominator = 1/3 → 33.333…% of revenue. +async function openFeesStep(harness: DtfHarness) { + harness.mock + .ethCall( + INDEX_DEPLOYER, + DAO_FEE_REGISTRY, + encodeAbiParameters([{ type: 'address' }], [FEE_REGISTRY]) + ) + .ethCall( + FEE_REGISTRY, + encodeFunctionData({ + abi: FEE_DETAILS_ABI, + functionName: 'getFeeDetails', + args: [zeroAddress], + }), + encodeAbiParameters( + [ + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint256' }, + { type: 'uint256' }, + ], + [FEE_REGISTRY, 1n, 3n, 0n] + ) + ) + + const { page } = harness + await page.goto('/internal/deploy') + await page.getByTestId('deploy-step-revenue-distribution').click() + + // 1/3 of revenue on the 0.01% grid — NOT the integer-truncated 33%. + await expect(page.getByTestId('deploy-platform-fee')).toHaveText('33.33 %', { + timeout: 15_000, + }) + + return page +} + +test('deploy fees: hundredths-of-a-percent shares close the allocation @smoke @mobile', async ({ + harness, +}) => { + const page = await openFeesStep(harness) + + await page.getByTestId('deploy-share-deployerShare').fill('0.01') + await page.getByTestId('deploy-share-governanceShare').fill('66.66') + + await expect(page.getByTestId('deploy-remaining-allocation')).toHaveText('0%') +}) + +test('deploy fees: even distribution allocates the whole non-platform pot', async ({ + harness, +}) => { + const page = await openFeesStep(harness) + + await page.getByTestId('deploy-even-distribution').click() + + // 66.67% over two participants — the last one absorbs the odd basis point. + await expect(page.getByTestId('deploy-share-deployerShare')).toHaveValue( + '33.33' + ) + await expect(page.getByTestId('deploy-share-governanceShare')).toHaveValue( + '33.34' + ) + await expect(page.getByTestId('deploy-remaining-allocation')).toHaveText('0%') +}) diff --git a/src/hooks/use-platform-fee.ts b/src/hooks/use-platform-fee.ts index a5af13b4b..42a6f4383 100644 --- a/src/hooks/use-platform-fee.ts +++ b/src/hooks/use-platform-fee.ts @@ -2,6 +2,7 @@ import daoFeeRegistryAbi from '@/abis/dao-fee-registry-abi' import dtfIndexDeployerAbi from '@/abis/dtf-index-deployer-abi' import { INDEX_DEPLOYER_ADDRESS } from '@/utils/addresses' import { FALLBACK_PLATFORM_FEES } from '@/utils/constants' +import { platformFeePercent } from '@/utils/fees' import { Address, zeroAddress } from 'viem' import { useReadContract } from 'wagmi' @@ -29,7 +30,7 @@ const usePlatformFee = (chainId: number): number => { if (!feeDetails) return fallback const [, feeNumerator, feeDenominator] = feeDetails - return Number(feeNumerator * 100n / feeDenominator) + return platformFeePercent(feeNumerator, feeDenominator) ?? fallback } export default usePlatformFee diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 926f0a0e7..39d9d0ba7 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -291,7 +291,7 @@ export const INDEX_DTF_CHAINS = [ export const FALLBACK_PLATFORM_FEES: Record = { [ChainId.Mainnet]: 50, [ChainId.Base]: 50, - [ChainId.BSC]: 33, + [ChainId.BSC]: 33.33, } // Load environment variables. diff --git a/src/utils/fees.ts b/src/utils/fees.ts index a07269eb5..fad49edba 100644 --- a/src/utils/fees.ts +++ b/src/utils/fees.ts @@ -5,3 +5,90 @@ export const isDisplayablePlatformFee = (platformFee: number): boolean => // Converts a contract percentage (share of NON-platform revenue) to share of total; guard with isDisplayablePlatformFee first. export const getFeePercentAdjust = (platformFee: number): number => 100 / (100 - platformFee) + +// Revenue shares are entered at 0.01% precision, so every fee percentage lives on +// the same basis-point grid — otherwise the shares can never total 100%. +const BPS_PER_PERCENT = 100 +const TOTAL_BPS = 100n * BigInt(BPS_PER_PERCENT) +const ONE = 10n ** 18n + +const toBps = (percent: number): bigint => + BigInt(Math.round(percent * BPS_PER_PERCENT)) + +export const quantizeFeePercent = (percent: number): number => + Number.isFinite(percent) ? Number(toBps(percent)) / BPS_PER_PERCENT : percent + +// Platform fee percentage from the DAO fee registry's fraction; undefined when unreadable. +export const platformFeePercent = ( + numerator: bigint, + denominator: bigint +): number | undefined => + denominator === 0n + ? undefined + : quantizeFeePercent( + Number((numerator * 10n ** 9n) / denominator) / 10 ** 7 + ) + +// Share of TOTAL revenue → the contract's portion of the NON-platform pot, in 18 decimals. +export const revenuePortionFromShare = ( + sharePercent: number, + platformFee: number +): bigint => { + if (!Number.isFinite(sharePercent) || !Number.isFinite(platformFee)) return 0n + + const shareBps = toBps(sharePercent) + const nonPlatformBps = TOTAL_BPS - toBps(platformFee) + + return nonPlatformBps > 0n + ? (shareBps * ONE) / nonPlatformBps + : (shareBps * ONE) / TOTAL_BPS +} + +// Rounding each contract portion to the 0.01% grid independently can leave the +// displayed shares a few hundredths off the pot, which reads back as an +// over/under-allocated form nobody edited. The largest share absorbs the drift; +// a gap bigger than one grid step per share is real, so it is left alone. +export const absorbShareDrift = ( + shares: number[], + totalPercent: number +): number[] => { + if (!shares.length || !Number.isFinite(totalPercent)) return shares + + const driftBps = + toBps(totalPercent) - shares.reduce((sum, share) => sum + toBps(share), 0n) + + const tolerance = BigInt(shares.length) + + if (driftBps === 0n || driftBps > tolerance || driftBps < -tolerance) + return shares + + const largest = shares.reduce( + (best, share, index) => (share > shares[best] ? index : best), + 0 + ) + const correctedBps = toBps(shares[largest]) + driftBps + + if (correctedBps < 0n) return shares + + return shares.map((share, index) => + index === largest ? Number(correctedBps) / BPS_PER_PERCENT : share + ) +} + +// Even split of a pot across participants; the last one absorbs the remainder so the shares total the pot exactly. +export const splitSharesEvenly = ( + totalPercent: number, + participants: number +): number[] => { + if (participants <= 0) return [] + + const totalBps = toBps(totalPercent) + const baseBps = totalBps / BigInt(participants) + const lastBps = totalBps - baseBps * BigInt(participants - 1) + + return Array.from( + { length: participants }, + (_, index) => + Number(index === participants - 1 ? lastBps : baseBps) / BPS_PER_PERCENT + ) +} diff --git a/src/utils/tests/fees.test.ts b/src/utils/tests/fees.test.ts index 4c7f33ae6..b4365e0e7 100644 --- a/src/utils/tests/fees.test.ts +++ b/src/utils/tests/fees.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest' -import { getFeePercentAdjust, isDisplayablePlatformFee } from '../fees' +import { parseEther } from 'viem' +import { + absorbShareDrift, + getFeePercentAdjust, + isDisplayablePlatformFee, + platformFeePercent, + quantizeFeePercent, + revenuePortionFromShare, + splitSharesEvenly, +} from '../fees' describe('isDisplayablePlatformFee', () => { it('accepts a finite fee in [0, 100)', () => { @@ -34,3 +43,92 @@ describe('getFeePercentAdjust', () => { expect(getFeePercentAdjust(20)).toBe(100 / 80) }) }) + +describe('platformFeePercent', () => { + it('keeps basis-point precision for a fractional registry fee', () => { + // BSC DAO fee registry: numerator/denominator = 1/3 → 33.333…% of revenue. + // Integer rounding reported it as 33%, which shifted every recipient share. + expect(platformFeePercent(333333333333333333n, 10n ** 18n)).toBe(33.33) + }) + + it('reads an exact fee unchanged', () => { + expect(platformFeePercent(5n * 10n ** 17n, 10n ** 18n)).toBe(50) + expect(platformFeePercent(0n, 10n ** 18n)).toBe(0) + }) + + it('is undefined for a zero denominator (unreadable registry)', () => { + expect(platformFeePercent(1n, 0n)).toBeUndefined() + }) +}) + +describe('quantizeFeePercent', () => { + it('snaps to the 0.01% grid the share inputs accept', () => { + expect(quantizeFeePercent(33.333333333333336)).toBe(33.33) + expect(quantizeFeePercent(50)).toBe(50) + }) + + it('passes through a non-finite value untouched', () => { + expect(quantizeFeePercent(NaN)).toBeNaN() + }) +}) + +describe('revenuePortionFromShare', () => { + it('gives the whole non-platform pot to a share equal to it', () => { + expect(revenuePortionFromShare(66.67, 33.33)).toBe(parseEther('1')) + expect(revenuePortionFromShare(50, 50)).toBe(parseEther('1')) + }) + + it('never exceeds the pot for a fractional platform fee', () => { + // parseEther on the float quotient overflowed 1e18 here (66.67/66.6667). + expect(revenuePortionFromShare(66.67, 33.33)).toBeLessThanOrEqual( + parseEther('1') + ) + expect(revenuePortionFromShare(33.34, 33.33)).toBeLessThanOrEqual( + parseEther('1') + ) + }) + + it('scales a partial share by the non-platform pot', () => { + expect(revenuePortionFromShare(25, 50)).toBe(parseEther('0.5')) + expect(revenuePortionFromShare(60, 0)).toBe(parseEther('0.6')) + }) + + it('falls back to the share of total when the pot is empty', () => { + expect(revenuePortionFromShare(50, 100)).toBe(parseEther('0.5')) + }) +}) + +describe('absorbShareDrift', () => { + it('puts a hundredth of drift on the largest share', () => { + expect(absorbShareDrift([56.72, 4.98, 4.98], 66.67)).toEqual([ + 56.71, 4.98, 4.98, + ]) + expect(absorbShareDrift([56.7, 4.98, 4.98], 66.67)).toEqual([ + 56.71, 4.98, 4.98, + ]) + }) + + it('leaves shares that already total the pot untouched', () => { + expect(absorbShareDrift([33.33, 33.34], 66.67)).toEqual([33.33, 33.34]) + }) + + it('leaves a real mismatch alone', () => { + expect(absorbShareDrift([50, 10], 66.67)).toEqual([50, 10]) + expect(absorbShareDrift([], 66.67)).toEqual([]) + }) +}) + +describe('splitSharesEvenly', () => { + it('totals the pot exactly when it does not divide evenly', () => { + expect(splitSharesEvenly(66.67, 2)).toEqual([33.33, 33.34]) + expect(splitSharesEvenly(66.67, 3)).toEqual([22.22, 22.22, 22.23]) + }) + + it('splits an even pot into equal shares', () => { + expect(splitSharesEvenly(50, 2)).toEqual([25, 25]) + }) + + it('returns nothing without participants', () => { + expect(splitSharesEvenly(50, 0)).toEqual([]) + }) +}) diff --git a/src/views/index-dtf/deploy/components/basic-input.tsx b/src/views/index-dtf/deploy/components/basic-input.tsx index 4a895f80c..30efa0153 100644 --- a/src/views/index-dtf/deploy/components/basic-input.tsx +++ b/src/views/index-dtf/deploy/components/basic-input.tsx @@ -20,7 +20,8 @@ export type BasicInputProps = { highlightLabel?: boolean decimalPlaces?: number autoFocus?: boolean - inputProps?: React.ComponentProps, + testId?: string + inputProps?: React.ComponentProps } const BasicInput = ({ @@ -34,6 +35,7 @@ const BasicInput = ({ highlightLabel = false, autoFocus = false, decimalPlaces, + testId, inputProps, ...props }: React.HTMLAttributes & BasicInputProps) => { @@ -78,6 +80,7 @@ const BasicInput = ({ } }} type={type} + data-testid={testId} placeholder={placeholder} startAdornment={ labelPosition === 'start' ? adornment : undefined diff --git a/src/views/index-dtf/deploy/components/deploy-accordion.tsx b/src/views/index-dtf/deploy/components/deploy-accordion.tsx index 8723c84ca..fbbc51af7 100644 --- a/src/views/index-dtf/deploy/components/deploy-accordion.tsx +++ b/src/views/index-dtf/deploy/components/deploy-accordion.tsx @@ -131,6 +131,7 @@ const DeployAccordionTrigger = ({ return ( { } }} > - {DEPLOY_STEPS.filter((step) => !readonlySteps.has(step.id)).map(({ id, icon, title, titleSecondary, content }) => ( - - - -
- {t(titleSecondary)} -
- {content} -
-
- ))} + {DEPLOY_STEPS.filter((step) => !readonlySteps.has(step.id)).map( + ({ id, icon, title, titleSecondary, content }) => ( + + + +
+ {t(titleSecondary)} +
+ {content} +
+
+ ) + )} ) } diff --git a/src/views/index-dtf/deploy/permissionless-defaults.ts b/src/views/index-dtf/deploy/permissionless-defaults.ts index fd0eff103..2c0ae4ee8 100644 --- a/src/views/index-dtf/deploy/permissionless-defaults.ts +++ b/src/views/index-dtf/deploy/permissionless-defaults.ts @@ -1,4 +1,5 @@ import { ChainId } from '@/utils/chains' +import { quantizeFeePercent } from '@/utils/fees' import { Address, zeroAddress } from 'viem' import { DeployInputs, DeployStepId } from './form-fields' @@ -59,7 +60,7 @@ export const getPermissionlessDefaults = ( // Fees folioFee: 0.15, mintFee: 0.15, - governanceShare: 100 - platformFee, + governanceShare: quantizeFeePercent(100 - platformFee), deployerShare: 0, fixedPlatformFee: platformFee, additionalRevenueRecipients: [], diff --git a/src/views/index-dtf/deploy/steps/revenue/revenue-distribution-settings.tsx b/src/views/index-dtf/deploy/steps/revenue/revenue-distribution-settings.tsx index 033838995..a79af12eb 100644 --- a/src/views/index-dtf/deploy/steps/revenue/revenue-distribution-settings.tsx +++ b/src/views/index-dtf/deploy/steps/revenue/revenue-distribution-settings.tsx @@ -3,13 +3,10 @@ import { msg } from '@lingui/core/macro' import { Trans, useLingui } from '@lingui/react/macro' import type { MessageDescriptor } from '@lingui/core' import { useAtomValue } from 'jotai' -import { - Landmark, - LandPlot, - TrainTrack, -} from 'lucide-react' +import { Landmark, LandPlot, TrainTrack } from 'lucide-react' import { ReactNode, useCallback, useEffect } from 'react' import { useFormContext, useWatch } from 'react-hook-form' +import { splitSharesEvenly } from '@/utils/fees' import { selectedGovernanceOptionAtom } from '../../atoms' import BasicInput from '../../components/basic-input' import { Decimal } from '../../utils/decimals' @@ -96,7 +93,10 @@ const RemainingAllocation = () => { Remaining allocation: {' '} - + {isNegative ? `-${displayValue}` : displayValue}% @@ -120,34 +120,42 @@ const EvenDistributionButton = () => { ].filter(Boolean).length const remainingPercentage = new Decimal(100).minus(platformFee) - const baseShare = - Math.floor((remainingPercentage.value / participantsCount) * 100) / 100 - const totalPercentage = baseShare * (participantsCount - 1) - const lastShare = +(remainingPercentage.value - totalPercentage).toFixed(2) + const shares = splitSharesEvenly( + remainingPercentage.value, + participantsCount + ) - setValue('deployerShare', baseShare) + setValue('deployerShare', shares[0]) if (isGovSharePresent) { - setValue('governanceShare', baseShare) + setValue('governanceShare', shares[1]) } if (isAdditionalRecipientsPresent) { + const additionalShares = shares.slice(isGovSharePresent ? 2 : 1) + setValue( 'additionalRevenueRecipients', additionalRecipients.map( (recipient: { address: string; share: number }, index: number) => ({ ...recipient, - share: - index === additionalRecipients.length - 1 ? lastShare : baseShare, + share: additionalShares[index], }) ) ) + + // The recipient inputs register per index, and a write to the array alone + // leaves them showing their old share. + additionalShares.forEach((share, index) => + setValue(`additionalRevenueRecipients[${index}].share`, share) + ) } }, [getValues, setValue, selectedGovOption]) return (