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
3 changes: 2 additions & 1 deletion docs/wiki/progress.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Progress
updated: 2026-07-31
updated: 2026-08-04
type: ledger
---

Expand All @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions e2e/TEST_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/*`)

Expand Down Expand Up @@ -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.
Expand Down
88 changes: 88 additions & 0 deletions e2e/tests/general/deploy/index-dtf-revenue.spec.ts
Original file line number Diff line number Diff line change
@@ -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%')
})
Comment on lines +62 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add lifecycle assertions for the deploy route.

This spec only asserts loaded-state behavior. It does not cover L0 blank, L1 skeleton, L2 partial resolution, L3 completion, or unexpected reflows. Add these assertions for desktop and the @mobile project before treating this as route coverage. As per coding guidelines, “Test every page through loading phases L0 blank, L1 skeleton, L2 independently resolving partial data, and L3 fully loaded behavior; assert zero unexpected reflows.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/tests/general/deploy/index-dtf-revenue.spec.ts` around lines 62 - 88,
Extend the deploy route coverage around the existing fees tests and run it for
both desktop and `@mobile` projects. Add assertions for L0 blank, L1 skeleton, L2
independently resolving partial data, and L3 completed content, including a
check that no unexpected layout reflows occur; retain the existing allocation
behavior assertions.

Source: Coding guidelines

3 changes: 2 additions & 1 deletion src/hooks/use-platform-fee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export const INDEX_DTF_CHAINS = [
export const FALLBACK_PLATFORM_FEES: Record<number, number> = {
[ChainId.Mainnet]: 50,
[ChainId.Base]: 50,
[ChainId.BSC]: 33,
[ChainId.BSC]: 33.33,
}

// Load environment variables.
Expand Down
87 changes: 87 additions & 0 deletions src/utils/fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
100 changes: 99 additions & 1 deletion src/utils/tests/fees.test.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand Down Expand Up @@ -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([])
})
})
5 changes: 4 additions & 1 deletion src/views/index-dtf/deploy/components/basic-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export type BasicInputProps = {
highlightLabel?: boolean
decimalPlaces?: number
autoFocus?: boolean
inputProps?: React.ComponentProps<typeof Input>,
testId?: string
inputProps?: React.ComponentProps<typeof Input>
}

const BasicInput = ({
Expand All @@ -34,6 +35,7 @@ const BasicInput = ({
highlightLabel = false,
autoFocus = false,
decimalPlaces,
testId,
inputProps,
...props
}: React.HTMLAttributes<HTMLDivElement> & BasicInputProps) => {
Expand Down Expand Up @@ -78,6 +80,7 @@ const BasicInput = ({
}
}}
type={type}
data-testid={testId}
placeholder={placeholder}
startAdornment={
labelPosition === 'start' ? adornment : undefined
Expand Down
Loading
Loading