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
50 changes: 50 additions & 0 deletions docs/plans/vlrsr-self-appreciating-vaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# vlRSR Self-Appreciating Vote-Lock Vault Support

## Goal

Register correctly quotes, unlocks, and displays self-appreciating StakingVaults (first: vlRSR `0xE744C8157c346B2931807F42552c8CBc0BB6D34f` on BSC): unlock tab operates in shares via `redeem`, lock tab shows real shares-out, governance and portfolio show exchange rate + redeemable underlying instead of assuming 1:1. Powered by dtf-sdk 0.5.1 (`prepareVoteLockRedeem`, `VoteLockState.shareBalance`/`exchangeRate`, preview hooks).

## Current state

- vlRSR streams tokenJar-converted RSR into the vault (half-life drip); rate ≈ 1.0188, `getAllRewardTokens() = []`.
- Drawer assumes 1:1: `vote-lock.tsx` echoes input as shares out; `vote-unlock.tsx` takes an asset-denominated input labeled as vlRSR (`unlockBalanceAtom` = maxWithdraw); tx = `withdraw(assets)`.
- Governance card: hardcoded `AUTO_ACCRUING_REWARD_VAULTS` Set; "Automatic" branch requires `otherDtfCount > 0`.
- Portfolio rows show API `amount` (shares) priced 1:1 (API bug, out of scope).
- `previewDeposit`/`previewRedeem` are exact and ≈1:1 for legacy vaults → quotes need no vault-type detection.
- Subgraph has no per-user cost basis → no "earned" number until subgraph slice (separate repo).

## Non-goals

- reserve-api share-pricing fix; earn-view "Your lock" valuation; per-user earned before subgraph deploy; tokenJar UI/SDK plumbing; `optimisticStakingVaultAddress` map.

## Acceptance evidence

| Criterion | Evidence |
| --- | --- |
| Unlock = shares → redeem for all vaults | e2e flow decodes `redeem(shares, account, account)` calldata from txLog; visual check bsc/photon + one legacy vault |
| Lock quote shows previewDeposit shares | visual check both vault types (vlRSR ≈ input/1.0188; legacy = input), light+dark |
| Governance card catalog-driven, Automatic decoupled from otherDtfCount, rate/redeemable line | smoke governance spec extended on bsc/photon; visual check |
| Portfolio flagged rows show redeemable + rate, fallback to API on RPC failure | portfolio partial-response spec green; visual check |
| Scoped verify green per slice | `scope.mjs --base <fixed-point>` output; `--gate` at closeout |

## Test seams

- e2e `e2e/helpers/rpc.ts` callOverrides: add `convertToAssets` (`0x07a2d13a`) + per-address `previewDeposit` (`0xef8b30f7`) / `previewRedeem` (`0x4cdad506`) with 1.0188-rate values on bsc/photon vlRSR fixture.
- Drawer submit buttons via wallet fixture + txLog decode.
- SDK-side behavior already unit-tested in dtf-sdk (`vote-lock.test.ts`).

## Slices

- Slice B: drawer shares-based unlock + lock quote (src/components/vote-lock/); blocked by: SDK 0.5.1 (local-linked during dev; pin bump before merge)
- Slice C: catalog in constants + governance card rate/redeemable; blocked by: B (drawer state carries exchangeRate)
- Slice D: portfolio redeemable for flagged rows; blocked by: SDK 0.5.1 only (parallel to C)

## Follow-ups discovered during implementation

- ~~Yield staking table stRSR share-vs-asset bug~~ FIXED same day: `YourStakeCell` reads per-row `exchangeRate()` and mirrors reserve-api portfolioService math (`shares × rate × rsrPrice`); display now RSR-denominated like the TVL column. Verified against portfolioService.ts:606-611.
- MetaMask + BSC EIP-7825 failure mode (documented 2026-07-31): a wallet-node lag on a fresh approval makes the wallet's estimate revert → MetaMask falls back to 35% of block gas limit (19.25M) → BSC's 2²⁴ per-tx cap rejects it. Pre-existing race, fatal only since the Apr 2026 Osaka/Mendel hardfork; not caused by app code (no `gas` is set). Staled by Luis — revisit only if reports recur.

## Unresolved decisions

- e2e preview mocks: central fixed-value vs per-address 1.0188 overrides — decide while writing the drawer flow spec (leaning per-address for a real-rate assertion).
- Engineer review required before ship: SDK calldata surface (dtf-sdk) + withdraw→redeem tx change + governance card (register).
4 changes: 3 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-28
updated: 2026-07-31
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 |
|---|---|---|---|---|
| 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 |
| merge master RFQ into feature hardening | human-review-required (base 0237e747c; merged `origin/master` 981b634d7; observed CLS explicitly out of scope) | frozen install · scoped gate-equivalent: lint/typecheck, 832 unit, 70 helper, 56 smoke + 1 known fixme · zap 21 · production build · wiki-lint pending | Dark + Light pass; SDK 0.5.0 pin + zapper 2.7.1 reconciled; hardening guards preserved; engineer review required for live RFQ/intent execution | commit merge locally; do not push |
Expand Down Expand Up @@ -42,6 +43,7 @@ Stage ledger. One row per stage; keep entries short. Verifier = exact fresh comm
### E2E coverage debt (fail-loud workarounds to pay down)

- **Index/Arbitrum egress assertion owed**: a spec inspecting `boundaryRequests` asserting NO Index-domain call carries chainId 42161 (with a Yield-positive counterpart — dtf-yield keeps Arbitrum). A green smoke does NOT prove this: teardown only fails on unmocked calls and the RPC mock answers Arbitrum generically.
- **Vote-lock (vlRSR) uncovered paths** (Codex review 2026-07-31): external Earn/portfolio drawer first-open (the hook-order crash path — fixed, untested); portfolio live-zero + RPC-error fallback branches; governance card rate/redeemable presentation; a legacy 1:1 vault through the universal redeem path (drawer spec only covers vlRSR); earn cell RPC-failure fallback (skeleton→1:1).

A mock strict enough that a spec routes AROUND its gap silently shrinks the
covered surface. Every in-spec workaround belongs here as tracked debt — not a
Expand Down
1 change: 1 addition & 0 deletions e2e/TEST_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ the directory).
| Issuance – Manual | [issuance/manual-write](tests/index-dtf/issuance/manual-write.spec.ts), [flows/issuance-manual](tests/flows/issuance-manual.spec.ts), [flows/issuance-manual-boundaries](tests/flows/issuance-manual-boundaries.spec.ts), [flows/issuance-deprecated](tests/flows/issuance-deprecated.spec.ts), [flows/failures-issuance](tests/flows/failures-issuance.spec.ts), [flows/compliance-surfaces](tests/flows/compliance-surfaces.spec.ts) | mint/redeem full tx flow; MAX/minSharesOut/decimal-truncation/disabled-input math boundaries; mint-twice no re-approve; deprecated forces sell-only; reject/revert recovery; per-DTF restriction disables mint, redeem stays open | none | no | fixme below (redeem zero-slippage leg) |
| Issuance – Automated (CoW) | — | — | — | — | entirely uncovered, no testids yet |
| Governance – list/overview | [governance/lifecycle](tests/index-dtf/governance/lifecycle.spec.ts), [governance/photon-featured](tests/index-dtf/governance/photon-featured.spec.ts) | list skeleton→proposals; real captured proposal history renders | partial | yes | — |
| 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 | — |
Expand Down
30 changes: 30 additions & 0 deletions e2e/helpers/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ for (const address of [
'0x4200000000000000000000000000000000000006', // WETH
'0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', // cbBTC
'0xaB36452DbAC151bE02b16Ca17d8919826072f64a', // RSR
// BSC — mirrors TokenBalancesUpdater's ZAP_TOKENS[BSC]; RSR bsc is already
// seeded via the photon fixture's basket metadata, but that's incidental
// (per-fixture), so list the chain's canonical set explicitly like the
// other chains above.
'0x2170Ed0880ac9A755fd29B2688956BD959F933F8', // WETH
'0x55d398326f99059fF775485246999027B3197955', // USDT
]) {
knownTokenAddresses.add(address.toLowerCase())
}
Expand Down Expand Up @@ -438,6 +444,30 @@ function seedChainState() {
callOverrides[`${contract.toLowerCase()}:${selector}`] = ZERO_RETURN
}
}
// Vote-lock state multicall reads shares + rate off the vault. Baseline:
// zero share balance, identity (1:1) exchange rate — self-appreciating
// vault specs override convertToAssets per-address with a real rate.
if (metadata.stToken?.id) {
const vault = metadata.stToken.id.toLowerCase()
callOverrides[`${vault}:0x70a08231`] = ZERO_RETURN // balanceOf(account)
callOverrides[`${vault}:0x07a2d13a`] = encodeAbiParameters(
// convertToAssets(1e18) → 1e18
[{ type: 'uint256' }],
[10n ** 18n]
)
// The governance card's exchange-rate badge fires previewRedeem(1 share)
// UNCONDITIONALLY on every self-appreciating-vault DTF page view (no
// wallet needed) — every such page, not just vote-lock-drawer specs,
// needs an answer. Same identity baseline + same caveat as
// convertToAssets above: this answers ANY previewRedeem call on the
// vault (selector-only match), so a spec quoting a DIFFERENT share
// amount must still override it per-test with the real rate.
callOverrides[`${vault}:0x4cdad506`] = encodeAbiParameters(
// previewRedeem(1e18 shares) → 1e18 assets
[{ type: 'uint256' }],
[10n ** 18n]
)
}
}

// These balances are polled by the wallet updater on every Base page and are
Expand Down
62 changes: 61 additions & 1 deletion e2e/helpers/tests/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { describe, expect, it, vi } from 'vitest'
import { chainIdForUrl, handleRpcMethod, setYieldReplay, type RpcContext } from '../rpc'
import { MockOverrides } from '../overrides'
import type { TxRecord } from '../provider'
import { findDtfByAddress, TEST_ADDRESS } from '../registry'
import { findDtfByAddress, REGISTRY, TEST_ADDRESS } from '../registry'
import { loadSnapshot } from '../snapshots'

const HASH = `0x${'1'.repeat(64)}`

Expand Down Expand Up @@ -381,6 +382,34 @@ describe('connected-wallet yield defaults (KNOWN → silent zero, UNKNOWN → fa
})
})

// Non-yield (index) path: TokenBalancesUpdater polls balanceOf on the chain's
// ZAP_TOKENS + RSR_ADDRESS for every connected wallet, on every chain,
// regardless of which page is open. BSC's set (WETH, USDT) must be KNOWN
// centrally like Mainnet's and Base's, or the FIRST wallet-connected BSC spec
// fails loud on a boundary that has nothing to do with what it's testing.
describe('connected-wallet INDEX defaults (BSC ZAP tokens)', () => {
const call = (to: string, data: string, ctx: RpcContext, overrides?: MockOverrides) =>
handleRpcMethod('eth_call', [{ to, data }], { ...ctx, overrides })
const balanceOf = (owner: string) =>
encodeFunctionData({
abi: parseAbi(['function balanceOf(address)']),
functionName: 'balanceOf',
args: [owner as `0x${string}`],
})

it('BSC ZAP tokens (WETH, USDT): test-wallet balanceOf → 0, silently', () => {
const ctx = { ...context(), chainId: 56 }
for (const token of [
'0x2170Ed0880ac9A755fd29B2688956BD959F933F8', // WETH
'0x55d398326f99059fF775485246999027B3197955', // USDT
]) {
const r = call(token, balanceOf(TEST_ADDRESS), ctx) as `0x${string}`
expect(BigInt(r)).toBe(0n)
}
expect(ctx.log).not.toHaveBeenCalled()
})
})

describe('failure-oriented unmocked messages', () => {
it('names the function and points at the helper for an uncaptured yield read', () => {
setYieldReplay(1)
Expand All @@ -402,6 +431,37 @@ describe('failure-oriented unmocked messages', () => {
})
})

describe('vote-lock vault identity defaults (convertToAssets / previewRedeem)', () => {
const photon = REGISTRY.find((d) => d.slug === 'photon')!
interface PhotonSnapshot {
dtf: { stToken: { id: string } }
}
const { dtf } = loadSnapshot<PhotonSnapshot>(`${photon.snapshotDir}/dtf.json`)
const vault = dtf.stToken.id

// The governance card's exchange-rate badge reads previewRedeem(1 share)
// UNCONDITIONALLY for a self-appreciating vault (no wallet, no per-test
// seed) — every DTF page view needs a default, not just vote-lock-drawer
// specs, or the whole page fails loud on a read it doesn't control.
it('convertToAssets(1e18) and previewRedeem(1e18) answer identity (1:1) with no override', () => {
const abi = parseAbi([
'function convertToAssets(uint256) view returns (uint256)',
'function previewRedeem(uint256) view returns (uint256)',
])
const call = (functionName: 'convertToAssets' | 'previewRedeem') => {
const data = encodeFunctionData({ abi, functionName, args: [10n ** 18n] })
const result = handleRpcMethod('eth_call', [{ to: vault, data }], {
...context(),
chainId: photon.chainId,
}) as `0x${string}`
return decodeAbiParameters([{ type: 'uint256' }], result)[0]
}

expect(call('convertToAssets')).toBe(10n ** 18n)
expect(call('previewRedeem')).toBe(10n ** 18n)
})
})

describe('address-specific protocol versions', () => {
it('preserves captured v4 and v5 version gates', () => {
const v5 = findDtfByAddress('0x4dA9A0f397dB1397902070f93a4D6ddBC0E0E6e8')!
Expand Down
Loading
Loading