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
6 changes: 5 additions & 1 deletion docs/wiki/log.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Log
updated: 2026-07-28
updated: 2026-08-08
type: log
---

Expand Down Expand Up @@ -125,3 +125,7 @@ Play-by-play lives in git (PRs #1053/#1054/#1055/#1063, SDK PR #27). Durable out

- Production chain cycling was a multi-tab feedback loop: each mounted DTF route automatically reasserted its own Ethereum/Base/BSC chain against one wallet-global network. Transaction buttons and the zapper only exposed the changing state. Automatic switching remains, but only the focused, visible document may request it; focusing another DTF tab makes that tab the new chain owner. Index and Yield DTF route contexts share the same guard.
- Review caught two smaller bounce paths before closeout: Index initially targeted the lagging global chain atom instead of its provider identity, and cached focus state was not revalidated at the wallet-mutation boundary. Both are now regression-protected; future route-chain synchronization must use the route/provider chain directly.

## 2026-08-08

- DTF Settings confirmation was globally gated by deploy-time schema validity, so unchanged on-chain values with display-rounding drift blocked unrelated governance changes even though those values were omitted from proposal calldata. The gate now scopes errors to changed settings while preserving no-change and changed-invalid guards; the E2E regression models an unchanged invalid distribution plus a valid mandate update.
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-08
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 |
|---|---|---|---|---|
| Fix DTF settings confirm button | human-review-required (base 6854a370b) | RED: rounded seeded distribution blocked mandate confirm; GREEN: unit 878 incl. mapper 27/27 · focused E2E 5/5 · typecheck · lint | Dark HOLD on untested mapping → 27 exhaustive mapper tests → Dark PASS; Light PASS; CodeRabbit 2 Minor → resolved (test IDs + editable confirmed state) | PR #1084 open; Engineer review required before merge; wiki-lint blocked by pre-existing stale design-system page |
| 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
2 changes: 1 addition & 1 deletion e2e/TEST_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ the directory).
| Governance – vote-lock drawer | [flows/vote-lock-drawer](tests/flows/vote-lock-drawer.spec.ts) | unlock submits `redeem(shares, account, account)` with previewRedeem-quoted output (self-appreciating rate); lock tab shows previewDeposit shares-out quote | none | no | delegate tab; lock full tx (approve+deposit); reject/revert |
| Governance – proposal (view/vote) | [flows/governance-states](tests/flows/governance-states.spec.ts), [flows/governance-multichain](tests/flows/governance-multichain.spec.ts), [flows/governance-permissions](tests/flows/governance-permissions.spec.ts), [flows/governance-support-variants](tests/flows/governance-support-variants.spec.ts), [flows/governance-vote](tests/flows/governance-vote.spec.ts), [flows/governance-queue-execute](tests/flows/governance-queue-execute.spec.ts), [flows/governance-writes-v4](tests/flows/governance-writes-v4.spec.ts), [flows/failures-governance](tests/flows/failures-governance.spec.ts), [flows/governance-description-render](tests/flows/governance-description-render.spec.ts) | PENDING/DEFEATED/QUORUM_NOT_REACHED/EXECUTED/QUEUED states (×chains, v4 governor); For/Against/Abstain vote encode; zero-power/already-voted/window-closed CTA gating; canceller-gated cancel; vote/queue/execute full tx + reject/revert; markdown sanitizer XSS hardening (script/iframe/img-onerror) | none | no | optimistic governance flow (see gaps) |
| Governance – create Basket | [flows/governance-propose-basket](tests/flows/governance-propose-basket.spec.ts) | form renders current basket; empty-change guard blocks prepare | none | no | price/liquidity preview, submitted calldata assertion |
| Governance – create DTF Settings | [flows/governance-propose-dtf-settings](tests/flows/governance-propose-dtf-settings.spec.ts) | TVL fee / Mint fee round-trip into setter calldata; no-change keeps confirm disabled | none | no | — |
| Governance – create DTF Settings | [flows/governance-propose-dtf-settings](tests/flows/governance-propose-dtf-settings.spec.ts) | TVL fee / Mint fee round-trip into setter calldata; unchanged invalid distribution state does not block an unrelated mandate proposal; no-change keeps confirm disabled | none | no | — |
| Governance – create Basket Settings | [flows/governance-propose-basket-settings](tests/flows/governance-propose-basket-settings.spec.ts), [governance/fee-bounds](tests/index-dtf/governance/fee-bounds.spec.ts) | voting-period round-trips setVotingPeriod calldata (trading governor); single-action guard (no phantom threshold); no-change disabled; out-of-range TVL fee rejected | none | no | — |
| Governance – create DAO (Other) | [flows/governance-propose](tests/flows/governance-propose.spec.ts) | DAO-settings proposal full submit flow | none | no | — |
| Auctions – rebalance list | [auctions/lifecycle](tests/index-dtf/auctions/lifecycle.spec.ts), [flows/auctions](tests/flows/auctions.spec.ts), [flows/auctions-multichain](tests/flows/auctions-multichain.spec.ts) | list skeleton→list; idle/historical bucketing; auctions-less 0-metrics row; in-window active row (×chains) | partial | yes (lifecycle spec only) | — |
Expand Down
43 changes: 40 additions & 3 deletions e2e/tests/flows/governance-propose-dtf-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ const SET_MINT_FEE_ABI = parseAbi(['function setMintFee(uint256 _newFee)'])
const dtf = findDtfByAddress(DTF_ADDRESS)!

type DtfSnapshot = {
dtf: { ownerGovernance: { id: string }; stToken: { id: string } }
dtf: {
mandate: string
feeRecipients: string
ownerGovernance: { id: string }
stToken: { id: string }
}
}
const loadDtf = () => loadSnapshot<DtfSnapshot>(`${dtf.snapshotDir}/dtf.json`).dtf

Expand All @@ -48,14 +53,18 @@ const UINT_ZERO = encodeAbiParameters([{ type: 'uint256' }], [0n])

// Anchor the frozen clock inside the captured proposal's voting window so every
// time-derived read stays consistent with the snapshots.
async function bootProposeFees(page: Page, overrides: MockOverrides) {
async function bootProposeFees(
page: Page,
overrides: MockOverrides,
platformFee: readonly [numerator: bigint, denominator: bigint] = [1n, 5n]
) {
const snapshot = loadDtf()

// The propose form's revenue seeding is gated on the platform fee resolving
// (feeRecipientsAtom returns undefined otherwise). Since B1/M1 a failed
// registry read is 'unavailable' — it no longer falls back to a fabricated
// 50% that happened to unblock this form — so model a real registry read.
seedFeeRegistry(overrides, dtf, 1n, 5n) // platformFee = 20%
seedFeeRegistry(overrides, dtf, ...platformFee)

// bidsEnabled() (0x459cf24b) on the folio is read by the DTF-settings Updater
// (v5) but isn't in the central chain-state seed — answer it (inert: we never
Expand Down Expand Up @@ -118,6 +127,34 @@ const confirmButton = (page: Page) =>
const submitButton = (page: Page) =>
page.getByRole('button', { name: 'Submit proposal onchain' })

test('unchanged distribution rounding drift does not block a mandate change', async ({
page,
overrides,
}) => {
const snapshot = structuredClone(loadDtf())
const oneThird = '333333333333333333'
snapshot.feeRecipients = [
`${snapshot.stToken.id}:${oneThird}`,
`0x280730d9277EF586d58dB74c277Aa710ca8F87C9:${oneThird}`,
].join(',')
overrides.subgraph({ operationName: 'GetIndexDTF' }, { dtf: snapshot })

await bootProposeFees(page, overrides, [1n, 3n])

await expect(page.getByTestId('remaining-allocation')).toContainText('22.23%')
await page
.locator('#propose-section-mandate')
.getByRole('button')
.first()
.click()
await advanceTime(page, 1_000)
await page.getByTestId('mandate-input').fill(`${snapshot.mandate} updated`)
await advanceTime(page, 1_000)

await expect(page.getByTestId('basics-update')).toBeVisible()
await expect(confirmButton(page)).toBeEnabled()
})

test('TVL fee change: UI percent round-trips into setTVLFee calldata', async ({
page,
txLog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,6 @@ export const currentBasketTokensAtom = atom((get) => {
)
})

// Atom to track form validation state
export const isFormValidAtom = atom(true)

export const isProposalValidAtom = atom((get) => {
const removedBasketTokens = get(removedBasketTokensAtom)
const hasTokenNameChange = get(hasTokenNameChangeAtom)
Expand All @@ -207,8 +204,6 @@ export const isProposalValidAtom = atom((get) => {
const hasOptimisticGovernanceChanges = get(
hasOptimisticGovernanceChangesAtom
)
const isFormValid = get(isFormValidAtom)

const hasChanges =
removedBasketTokens.length > 0 ||
hasTokenNameChange ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ const BasicsChanges = () => {

return (
<ChangeSection
title={<Trans>Basics Update</Trans>}
title={
<span data-testid="basics-update">
<Trans>Basics Update</Trans>
</span>
}
icon={<FileText size={16} />}
>
{hasTokenNameChange && isV5 && tokenNameChange && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,44 +9,32 @@ import { Link } from 'react-router-dom'
import {
isProposalConfirmedAtom,
isProposalValidAtom,
isFormValidAtom,
dtfSettingsProposalDataAtom,
proposalDescriptionAtom,
} from '../atoms'
import DTFSettingsProposalChanges from './dtf-settings-proposal-changes'
import SubmitProposalButton from './submit-proposal-button'
import SimulateProposalCard from '@/views/index-dtf/governance/components/simulate-proposal-card'
import { chainIdAtom } from '@/state/atoms'
import { Address } from 'viem'
import { shouldBypassFormValidation } from '@/utils/form-validation'
import { useFormContext } from 'react-hook-form'
import { useHasRelevantFormErrors } from '../hooks/use-has-relevant-form-errors'

const ConfirmProposalButton = () => {
const ConfirmProposalButton = ({ canProceed }: { canProceed: boolean }) => {
const { t } = useLingui()
const isValid = useAtomValue(isProposalValidAtom)
const isFormValid = useAtomValue(isFormValidAtom)
const [isProposalConfirmed, setIsProposalConfirmed] = useAtom(
isProposalConfirmedAtom
)
const bypassFormValidation = shouldBypassFormValidation()

const handleConfirm = () => {
if (!isProposalConfirmed) {
// When confirming, check if form is valid
if (!isFormValid && !bypassFormValidation) {
// The form will show validation errors
return
}
}
if (!isProposalConfirmed && !canProceed) return
setIsProposalConfirmed(!isProposalConfirmed)
}

// Enable button only if there are changes AND form is valid
const isButtonEnabled = isValid && (isFormValid || bypassFormValidation)

return (
<Button
className="w-full"
disabled={!isButtonEnabled}
disabled={!canProceed && !isProposalConfirmed}
variant={isProposalConfirmed ? 'outline' : 'default'}
onClick={handleConfirm}
>
Expand All @@ -58,11 +46,14 @@ const ConfirmProposalButton = () => {
const ProposalInstructions = () => {
const { t } = useLingui()
const isValid = useAtomValue(isProposalValidAtom)
const isFormValid = useAtomValue(isFormValidAtom)
const confirmed = useAtomValue(isProposalConfirmedAtom)
const {
formState: { errors },
} = useFormContext()
const hasRelevantFormErrors = useHasRelevantFormErrors(errors)
const bypassFormValidation = shouldBypassFormValidation()

const canProceed = isValid && (isFormValid || bypassFormValidation)
const canProceed = isValid && (!hasRelevantFormErrors || bypassFormValidation)

const timelineItems = [
{
Expand All @@ -72,7 +63,7 @@ const ProposalInstructions = () => {
},
{
title: t`Finalize basket proposal`,
children: <ConfirmProposalButton />,
children: <ConfirmProposalButton canProceed={canProceed} />,
isActive: canProceed && !confirmed,
isCompleted: confirmed,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const ProposeMetadata = () => {
</FormLabel>
<FormControl>
<Textarea
data-testid="mandate-input"
placeholder={t`This Index DTF will…`}
rows={6}
className="px-1 text-base [&:focus::placeholder]:opacity-0 [&:focus::placeholder]:transition-opacity"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ const RemainingAllocation = () => {

return (
<div className="flex flex-col gap-2">
<div className="text-base ml-auto px-4">
<div data-testid="remaining-allocation" className="text-base ml-auto px-4">
<span className="text-muted-foreground">
<Trans>Remaining allocation:</Trans>
</span>{' '}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { FieldError, FieldErrors } from 'react-hook-form'
import type { ProposeSettings } from '../form-fields'

type SettingsErrors = FieldErrors<ProposeSettings> & {
optimistic?: FieldError
roles?: FieldError
'revenue-distribution'?: FieldError
}

export type RelevantFormChanges = {
tokenNameChange?: unknown
mandateChange?: unknown
rolesChanges: {
guardians?: unknown
brandManagers?: unknown
auctionLaunchers?: unknown
}
revenueDistributionChanges: {
governanceShare?: unknown
deployerShare?: unknown
additionalRecipients?: unknown
}
dtfRevenueChanges: { mintFee?: unknown; tvlFee?: unknown }
auctionLengthChange?: unknown
weightControlChange?: unknown
bidsEnabledChange?: unknown
governanceChanges: {
votingDelay?: unknown
votingPeriod?: unknown
quorumPercent?: unknown
proposalThreshold?: unknown
executionDelay?: unknown
}
optimisticChanges: {
vetoDelay?: unknown
vetoPeriod?: unknown
vetoThreshold?: unknown
optimisticProposers?: unknown
}
}

// Seeded values can fail deploy-time validation; only changed values enter calldata.
export const hasRelevantFormErrors = (
formErrors: FieldErrors,
changes: RelevantFormChanges
) => {
const errors = formErrors as SettingsErrors
const {
tokenNameChange,
mandateChange,
rolesChanges,
revenueDistributionChanges,
dtfRevenueChanges,
auctionLengthChange,
weightControlChange,
bidsEnabledChange,
governanceChanges,
optimisticChanges,
} = changes
const hasRolesChange =
rolesChanges.guardians !== undefined ||
rolesChanges.brandManagers !== undefined ||
rolesChanges.auctionLaunchers !== undefined
const hasDistributionChange =
revenueDistributionChanges.governanceShare !== undefined ||
revenueDistributionChanges.deployerShare !== undefined ||
revenueDistributionChanges.additionalRecipients !== undefined

return Boolean(
(tokenNameChange !== undefined && errors.tokenName) ||
(mandateChange !== undefined && errors.mandate) ||
(rolesChanges.guardians !== undefined && errors.guardians) ||
(rolesChanges.brandManagers !== undefined && errors.brandManagers) ||
(rolesChanges.auctionLaunchers !== undefined && errors.auctionLaunchers) ||
(hasRolesChange && errors.roles) ||
(revenueDistributionChanges.governanceShare !== undefined &&
errors.governanceShare) ||
(revenueDistributionChanges.deployerShare !== undefined &&
errors.deployerShare) ||
(revenueDistributionChanges.additionalRecipients !== undefined &&
errors.additionalRevenueRecipients) ||
(hasDistributionChange && errors['revenue-distribution']) ||
(dtfRevenueChanges.mintFee !== undefined && errors.mintFee) ||
(dtfRevenueChanges.tvlFee !== undefined && errors.folioFee) ||
(auctionLengthChange !== undefined && errors.auctionLength) ||
(weightControlChange !== undefined && errors.weightControl) ||
(bidsEnabledChange !== undefined && errors.bidsEnabled) ||
(governanceChanges.votingDelay !== undefined &&
errors.governanceVotingDelay) ||
(governanceChanges.votingPeriod !== undefined &&
errors.governanceVotingPeriod) ||
(governanceChanges.quorumPercent !== undefined &&
errors.governanceVotingQuorum) ||
(governanceChanges.proposalThreshold !== undefined &&
errors.governanceVotingThreshold) ||
(governanceChanges.executionDelay !== undefined &&
errors.governanceExecutionDelay) ||
(optimisticChanges.vetoDelay !== undefined && errors.optimisticVetoDelay) ||
(optimisticChanges.vetoPeriod !== undefined &&
errors.optimisticVetoPeriod) ||
(optimisticChanges.vetoThreshold !== undefined &&
errors.optimisticVetoThreshold) ||
(optimisticChanges.optimisticProposers !== undefined &&
(errors.optimisticProposers || errors.optimistic))
)
}
Loading
Loading