diff --git a/.changeset/vote-lock-self-appreciating.md b/.changeset/vote-lock-self-appreciating.md new file mode 100644 index 0000000..db02dd5 --- /dev/null +++ b/.changeset/vote-lock-self-appreciating.md @@ -0,0 +1,6 @@ +--- +"@reserve-protocol/sdk": patch +"@reserve-protocol/react-sdk": patch +--- + +Support self-appreciating vote-lock vaults (vlRSR): `prepareVoteLockRedeem` builder for shares-denominated unlocks, `VoteLockState.shareBalance` + `VoteLockState.exchangeRate` (same single multicall), and `useIndexDtfVoteLockPreviewDeposit` / `useIndexDtfVoteLockPreviewRedeem` hooks for lock/unlock quotes. diff --git a/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx b/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx index 565ca95..8e8aada 100644 --- a/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx +++ b/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx @@ -40,35 +40,37 @@ const price = useIndexDtfPrice({ address, chainId }); ## Product And Analytics Reads -| Hook | Core SDK method | -| ----------------------------------- | ------------------------------------- | -| `useIndexDtfStatus` | `sdk.index.getStatus` | -| `useIndexDtfExposure` | `sdk.index.getExposure` | -| `useIndexDtfHolders` | `sdk.index.getHolders` | -| `useIndexDtfAccountBalanceSnapshot` | `sdk.index.getAccountBalanceSnapshot` | -| `useIndexDtfTransactions` | `sdk.index.getTransactions` | -| `useIndexDtfRevenue` | `sdk.index.getRevenue` | -| `useIndexDtfPlatformFee` | `sdk.index.getPlatformFee` | -| `useIndexDtfPendingFeeShares` | `sdk.index.getPendingFeeShares` | -| `useIndexDtfApprovedRevenueTokens` | `sdk.index.getApprovedRevenueTokens` | -| `useIndexDtfIssuanceState` | `sdk.index.getIssuanceState` | -| `useIndexDtfBidsEnabled` | `sdk.index.getBidsEnabled` | -| `useIndexDtfRebalanceControl` | `sdk.index.getRebalanceControl` | -| `useIndexDtfRebalances` | `sdk.index.getRebalances` | -| `useIndexDtfRebalance` | `sdk.index.getRebalance` | -| `useIndexDtfCurrentRebalance` | `sdk.index.getCurrentRebalance` | -| `useIndexDtfRebalanceLiquidity` | `sdk.index.getRebalanceLiquidity` | -| `useIndexDtfRebalanceAuctions` | `sdk.index.getRebalanceAuctions` | -| `useIndexDtfActiveAuction` | `sdk.index.getActiveAuction` | -| `useIndexDtfLatestAuction` | `sdk.index.getLatestAuction` | -| `useIndexDtfBidQuote` | `sdk.index.getBidQuote` | -| `useIndexDtfCompletedRebalance` | `sdk.index.getCompletedRebalance` | -| `useIndexDtfCompletedRebalances` | `sdk.index.getCompletedRebalances` | -| `useIndexDtfVoteLockState` | `sdk.index.getVoteLockState` | -| `useIndexDtfVoteLockVaultState` | `sdk.index.getVoteLockVaultState` | -| `useAccountPortfolio` | `sdk.portfolio.get` | -| `useAccountPortfolioHistory` | `sdk.portfolio.getHistory` | -| `useAccountPortfolioTransactions` | `sdk.portfolio.getTransactions` | +| Hook | Core SDK method | +| ----------------------------------- | -------------------------------------- | +| `useIndexDtfStatus` | `sdk.index.getStatus` | +| `useIndexDtfExposure` | `sdk.index.getExposure` | +| `useIndexDtfHolders` | `sdk.index.getHolders` | +| `useIndexDtfAccountBalanceSnapshot` | `sdk.index.getAccountBalanceSnapshot` | +| `useIndexDtfTransactions` | `sdk.index.getTransactions` | +| `useIndexDtfRevenue` | `sdk.index.getRevenue` | +| `useIndexDtfPlatformFee` | `sdk.index.getPlatformFee` | +| `useIndexDtfPendingFeeShares` | `sdk.index.getPendingFeeShares` | +| `useIndexDtfApprovedRevenueTokens` | `sdk.index.getApprovedRevenueTokens` | +| `useIndexDtfIssuanceState` | `sdk.index.getIssuanceState` | +| `useIndexDtfBidsEnabled` | `sdk.index.getBidsEnabled` | +| `useIndexDtfRebalanceControl` | `sdk.index.getRebalanceControl` | +| `useIndexDtfRebalances` | `sdk.index.getRebalances` | +| `useIndexDtfRebalance` | `sdk.index.getRebalance` | +| `useIndexDtfCurrentRebalance` | `sdk.index.getCurrentRebalance` | +| `useIndexDtfRebalanceLiquidity` | `sdk.index.getRebalanceLiquidity` | +| `useIndexDtfRebalanceAuctions` | `sdk.index.getRebalanceAuctions` | +| `useIndexDtfActiveAuction` | `sdk.index.getActiveAuction` | +| `useIndexDtfLatestAuction` | `sdk.index.getLatestAuction` | +| `useIndexDtfBidQuote` | `sdk.index.getBidQuote` | +| `useIndexDtfCompletedRebalance` | `sdk.index.getCompletedRebalance` | +| `useIndexDtfCompletedRebalances` | `sdk.index.getCompletedRebalances` | +| `useIndexDtfVoteLockState` | `sdk.index.getVoteLockState` | +| `useIndexDtfVoteLockVaultState` | `sdk.index.getVoteLockVaultState` | +| `useIndexDtfVoteLockPreviewDeposit` | `sdk.index.readVoteLockPreviewDeposit` | +| `useIndexDtfVoteLockPreviewRedeem` | `sdk.index.readVoteLockPreviewRedeem` | +| `useAccountPortfolio` | `sdk.portfolio.get` | +| `useAccountPortfolioHistory` | `sdk.portfolio.getHistory` | +| `useAccountPortfolioTransactions` | `sdk.portfolio.getTransactions` | `useIndexDtfStatus` is synchronous and returns the catalog status directly. Query-backed hooks accept TanStack Query options; `useIndexDtfPerformance` reserves `select` for its raw-cache-preserving composition. Use `usePrefetchIndexDtfPriceHistory` to warm canonical price-history keys. diff --git a/apps/docs/src/pages/sdk/vote-lock.mdx b/apps/docs/src/pages/sdk/vote-lock.mdx index 124a572..d8ee7c2 100644 --- a/apps/docs/src/pages/sdk/vote-lock.mdx +++ b/apps/docs/src/pages/sdk/vote-lock.mdx @@ -32,17 +32,18 @@ The SDK exposes raw read helpers for vote-lock contracts. ## Builders -| Method | Use | -| ----------------------------------- | ----------------------------------------------------------- | -| `prepareVoteLockDeposit` | Deposit underlying into a vote-lock vault. | -| `prepareVoteLockDepositPlan` | Deposit plan with optional underlying approval. | -| `prepareVoteLockDelegate` | Delegate normal voting power. | -| `prepareVoteLockDelegateOptimistic` | Delegate optimistic voting power. | -| `prepareVoteLockWithdraw` | Start or execute withdraw flow depending on vault behavior. | -| `prepareVoteLockClaimRewards` | Claim reward tokens. | -| `prepareVoteLockClaimLock` | Claim an unstaking lock. | -| `prepareVoteLockCancelLock` | Cancel an unstaking lock. | -| `prepareVoteLockPoke` | Poke reward/account accounting. | +| Method | Use | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `prepareVoteLockDeposit` | Deposit underlying into a vote-lock vault. | +| `prepareVoteLockDepositPlan` | Deposit plan with optional underlying approval. | +| `prepareVoteLockDelegate` | Delegate normal voting power. | +| `prepareVoteLockDelegateOptimistic` | Delegate optimistic voting power. | +| `prepareVoteLockWithdraw` | Start or execute withdraw flow depending on vault behavior. | +| `prepareVoteLockRedeem` | Shares-denominated unlock (`redeem`). Correct for every vault (legacy 1:1 and self-appreciating); Register uses it universally. | +| `prepareVoteLockClaimRewards` | Claim reward tokens. | +| `prepareVoteLockClaimLock` | Claim an unstaking lock. | +| `prepareVoteLockCancelLock` | Cancel an unstaking lock. | +| `prepareVoteLockPoke` | Poke reward/account accounting. | ```ts const plan = sdk.index.prepareVoteLockDepositPlan({ diff --git a/docs/index-dtf/vote-lock.md b/docs/index-dtf/vote-lock.md index 732a225..d857cc5 100644 --- a/docs/index-dtf/vote-lock.md +++ b/docs/index-dtf/vote-lock.md @@ -31,10 +31,10 @@ Users must delegate voting power for votes to count. Unlock behavior: -1. User chooses amount to unlock. -2. Register checks `amount <= maxWithdraw`. -3. User calls `withdraw(amount, account, account)` to begin unlock. -4. Rewards stop according to vault behavior. +1. User chooses the share amount to unlock (input/balance are vault shares). +2. Register checks `shares <= balanceOf(account)` and quotes the underlying out via `previewRedeem(shares)`. +3. User calls `redeem(shares, account, account)` to begin unlock (`prepareVoteLockRedeem`). This is the universal path for every vault — legacy 1:1 and self-appreciating alike; the contract converts shares to underlying at the current rate and escrows that fixed underlying amount. +4. Rewards stop according to vault behavior; the escrowed amount no longer appreciates. 5. User waits unlock delay. 6. User returns to claim/withdraw unlocked underlying when available. diff --git a/docs/wiki/progress.md b/docs/wiki/progress.md index b49a181..0ce0885 100644 --- a/docs/wiki/progress.md +++ b/docs/wiki/progress.md @@ -1,6 +1,6 @@ --- title: Progress -updated: 2026-07-22 +updated: 2026-07-31 type: ledger --- @@ -8,13 +8,14 @@ type: ledger Stage ledger. One row per stage; keep entries short. Verifier = exact fresh commands that ran green. Lenses = one line each in the Review column. -| Stage | Status | Verifier | Review | Next | -| ----------------------------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| 0.5.0 hardening release closeout | done (base f1ee8f5) | Node 24 `release:ci`: types + lint/format + live codegen + 394 tests (+17 live skipped) + build/bundle + 93 docs + catalog + 3 LICENSE-bearing tarballs; forced closeout gate + wiki-lint green | correctness+security+product+complexity: PR #27 reconciled; zero values, status validation, timestamp selection, namespace/ref/hook coverage, schema drift, publish gate, docs, and linked Register RED→GREEN verified | engineer review; commit/push; Changesets release to 0.5.0 | -| governance tie semantics + yield list state + rebalance hardening tests | done (base 588954e) | full gate on Node 24: forced builds + sdk-bundle + typecheck + lint + format + forced tests (sdk 287 passed/17 live skipped, react-sdk 72) + docs links + catalog checks; wiki-lint green | correctness+security+product+complexity: Dark+Light subagent pair; adopted boundary/mixed-flavor vectors and pinned zero-supply message; PENDING-expired labeling and detail QUORUM_NOT_REACHED split verified against Register reference, sent to backlog | Luis review (user-visible governance badge change); release patch | -| Current DTF aggregate, rebalance health, and bundle packaging | done (base 4bcda6a) | `turbo build --force` + SDK bundle gate + typecheck + lint + format + forced tests (340 passed, 17 live skipped) + 93-doc links + catalog checks | correctness+security+product+complexity: self-review, no blockers; external reviewer skipped because delegation was not authorized for closeout | release SDK/React SDK; replace Register local links with released versions | -| SDK audit and Register gap closure | done (base 4bcda6a) | forced builds + types + lint/format + 331 tests (+17 live skipped) + 93-doc links/routes + catalog; release:ci pack dry-runs; workflow 24 tests | correctness+security+product+complexity: independent review; fixed dirty-tree codegen, public-route coverage, docs routing, fresh-build gate | release SDK/React SDK and bump Register | -| workflow adoption | done (base 4bcda6a) | build + typecheck + lint (2 baseline warnings) + format + 321 tests + docs + catalog checks; workflow 24 tests | correctness+complexity: independent review; fixed build/codegen/risk routing, rule preservation, formatter ownership, attribute precedence | audit SDK/React SDK and Register gaps | +| Stage | Status | Verifier | Review | Next | +| ----------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| vote-lock self-appreciating vault support (0.5.1) | human-review-required (base 9231139) | scoped verify green: format/lint/typecheck/test both packages (vote-lock.test 2, query-keys 12) + bundle + docs:links + wiki-lint | Dark + Light; blocker fixed (redeem builder missing from index-dtf/index.ts + index.ts barrels); multicall-order assertion added; patch changeset per Luis (0.5.1, though API additions are minor-shaped) | **Engineer review required** (redeem calldata builder + VoteLockState staking surface); then changesets release 0.5.1; register consumes via local link until published | +| 0.5.0 hardening release closeout | done (base f1ee8f5) | Node 24 `release:ci`: types + lint/format + live codegen + 394 tests (+17 live skipped) + build/bundle + 93 docs + catalog + 3 LICENSE-bearing tarballs; forced closeout gate + wiki-lint green | correctness+security+product+complexity: PR #27 reconciled; zero values, status validation, timestamp selection, namespace/ref/hook coverage, schema drift, publish gate, docs, and linked Register RED→GREEN verified | engineer review; commit/push; Changesets release to 0.5.0 | +| governance tie semantics + yield list state + rebalance hardening tests | done (base 588954e) | full gate on Node 24: forced builds + sdk-bundle + typecheck + lint + format + forced tests (sdk 287 passed/17 live skipped, react-sdk 72) + docs links + catalog checks; wiki-lint green | correctness+security+product+complexity: Dark+Light subagent pair; adopted boundary/mixed-flavor vectors and pinned zero-supply message; PENDING-expired labeling and detail QUORUM_NOT_REACHED split verified against Register reference, sent to backlog | Luis review (user-visible governance badge change); release patch | +| Current DTF aggregate, rebalance health, and bundle packaging | done (base 4bcda6a) | `turbo build --force` + SDK bundle gate + typecheck + lint + format + forced tests (340 passed, 17 live skipped) + 93-doc links + catalog checks | correctness+security+product+complexity: self-review, no blockers; external reviewer skipped because delegation was not authorized for closeout | release SDK/React SDK; replace Register local links with released versions | +| SDK audit and Register gap closure | done (base 4bcda6a) | forced builds + types + lint/format + 331 tests (+17 live skipped) + 93-doc links/routes + catalog; release:ci pack dry-runs; workflow 24 tests | correctness+security+product+complexity: independent review; fixed dirty-tree codegen, public-route coverage, docs routing, fresh-build gate | release SDK/React SDK and bump Register | +| workflow adoption | done (base 4bcda6a) | build + typecheck + lint (2 baseline warnings) + format + 321 tests + docs + catalog checks; workflow 24 tests | correctness+complexity: independent review; fixed build/codegen/risk routing, rule preservation, formatter ownership, attribute precedence | audit SDK/React SDK and Register gaps | ## Backlog diff --git a/packages/react-sdk/src/index-dtf-extra-hooks.ts b/packages/react-sdk/src/index-dtf-extra-hooks.ts index fcc600d..8692a09 100644 --- a/packages/react-sdk/src/index-dtf-extra-hooks.ts +++ b/packages/react-sdk/src/index-dtf-extra-hooks.ts @@ -33,6 +33,8 @@ import { indexDtfTotalSupplyQueryOptions, indexDtfVoteLockStateQueryOptions, indexDtfVoteLockVaultStateQueryOptions, + indexDtfVoteLockPreviewDepositQueryOptions, + indexDtfVoteLockPreviewRedeemQueryOptions, } from "@/index-dtf-query-options"; import { useDtfSdk } from "@/provider"; @@ -251,6 +253,22 @@ export function useIndexDtfVoteLockVaultState>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfVoteLockPreviewDepositQueryOptions(sdk, params, options)); +} + +export function useIndexDtfVoteLockPreviewRedeem>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfVoteLockPreviewRedeemQueryOptions(sdk, params, options)); +} + export function useAccountPortfolio>>( params: MethodParams> | undefined, options?: DtfQueryOptions>, TData>, diff --git a/packages/react-sdk/src/index-dtf-query-options.ts b/packages/react-sdk/src/index-dtf-query-options.ts index a2ed717..bae89ec 100644 --- a/packages/react-sdk/src/index-dtf-query-options.ts +++ b/packages/react-sdk/src/index-dtf-query-options.ts @@ -240,6 +240,38 @@ export function indexDtfVoteLockVaultStateQueryOptions>, +>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.voteLockPreviewDeposit(params), + () => sdk.index.readVoteLockPreviewDeposit(requireParams(params, "indexDtfVoteLockPreviewDepositQueryOptions")), + params !== undefined, + options, + LIVE_STALE_TIME, + ); +} + +export function indexDtfVoteLockPreviewRedeemQueryOptions< + TData = MethodResult>, +>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.voteLockPreviewRedeem(params), + () => sdk.index.readVoteLockPreviewRedeem(requireParams(params, "indexDtfVoteLockPreviewRedeemQueryOptions")), + params !== undefined, + options, + LIVE_STALE_TIME, + ); +} + export function accountPortfolioQueryOptions>>( sdk: DtfSdk, params: MethodParams> | undefined, diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index 83d3e0b..096cbfe 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -119,6 +119,8 @@ export { indexDtfTotalSupplyQueryOptions, indexDtfVoteLockStateQueryOptions, indexDtfVoteLockVaultStateQueryOptions, + indexDtfVoteLockPreviewDepositQueryOptions, + indexDtfVoteLockPreviewRedeemQueryOptions, } from "@/index-dtf-query-options"; export { useYieldDtf, @@ -212,6 +214,8 @@ export { useIndexDtfTotalSupply, useIndexDtfVoteLockState, useIndexDtfVoteLockVaultState, + useIndexDtfVoteLockPreviewDeposit, + useIndexDtfVoteLockPreviewRedeem, } from "@/index-dtf-extra-hooks"; export { composeIndexDtfPerformance, diff --git a/packages/react-sdk/src/query-keys.test.ts b/packages/react-sdk/src/query-keys.test.ts index 15d54f0..e31a982 100644 --- a/packages/react-sdk/src/query-keys.test.ts +++ b/packages/react-sdk/src/query-keys.test.ts @@ -331,6 +331,41 @@ describe("dtfQueryKeys", () => { }, ]); }); + + it("normalizes bigint amounts in vote-lock preview keys", () => { + expect( + dtfQueryKeys.index.voteLockPreviewDeposit({ + stToken: "0x000000000000000000000000000000000000000A", + chainId: 56, + amount: 10n ** 18n, + }), + ).toEqual([ + "dtf", + "index", + "vote-lock-preview-deposit", + { + stToken: "0x000000000000000000000000000000000000000a", + chainId: 56, + amount: "1000000000000000000", + }, + ]); + expect( + dtfQueryKeys.index.voteLockPreviewRedeem({ + stToken: "0x000000000000000000000000000000000000000A", + chainId: 56, + shares: 5n, + }), + ).toEqual([ + "dtf", + "index", + "vote-lock-preview-redeem", + { + stToken: "0x000000000000000000000000000000000000000a", + chainId: 56, + shares: "5", + }, + ]); + }); }); function createDtfKeyFixture() { diff --git a/packages/react-sdk/src/query-keys.ts b/packages/react-sdk/src/query-keys.ts index a918e49..0d80c94 100644 --- a/packages/react-sdk/src/query-keys.ts +++ b/packages/react-sdk/src/query-keys.ts @@ -239,6 +239,10 @@ export const dtfQueryKeys = { [...dtfQueryKeys.index.all(), "vote-lock-state", keyParams(params)] as const, voteLockVaultState: (params?: IndexMethodParams<"getVoteLockVaultState">) => [...dtfQueryKeys.index.all(), "vote-lock-vault-state", keyParams(params)] as const, + voteLockPreviewDeposit: (params?: IndexMethodParams<"readVoteLockPreviewDeposit">) => + [...dtfQueryKeys.index.all(), "vote-lock-preview-deposit", keyParams(params)] as const, + voteLockPreviewRedeem: (params?: IndexMethodParams<"readVoteLockPreviewRedeem">) => + [...dtfQueryKeys.index.all(), "vote-lock-preview-redeem", keyParams(params)] as const, governance: { all: () => [...dtfQueryKeys.index.all(), "governance"] as const, proposalList: (params?: GetIndexDtfProposalsParams) => diff --git a/packages/sdk/src/index-dtf/index.ts b/packages/sdk/src/index-dtf/index.ts index b9a03ff..6bc06aa 100644 --- a/packages/sdk/src/index-dtf/index.ts +++ b/packages/sdk/src/index-dtf/index.ts @@ -280,6 +280,7 @@ export { prepareVoteLockRemoveRewardToken, prepareVoteLockSetRewardRatio, prepareVoteLockSetUnstakingDelay, + prepareVoteLockRedeem, prepareVoteLockWithdraw, } from "@/index-dtf/vote-lock/index"; diff --git a/packages/sdk/src/index-dtf/vote-lock/builders.ts b/packages/sdk/src/index-dtf/vote-lock/builders.ts index a60fc0f..987406e 100644 --- a/packages/sdk/src/index-dtf/vote-lock/builders.ts +++ b/packages/sdk/src/index-dtf/vote-lock/builders.ts @@ -190,6 +190,26 @@ export function prepareVoteLockWithdraw(params: { }); } +/** + * Prepares a staking-vault `redeem` call that starts an unlock when delay is + * enabled. Shares-denominated counterpart of `prepareVoteLockWithdraw` — + * correct for self-appreciating vaults where shares ≠ assets. + */ +export function prepareVoteLockRedeem(params: { + readonly stToken: Address; + readonly chainId: SupportedChainId; + readonly shares: bigint; + readonly account: Address; +}) { + return prepareContractCall({ + chainId: params.chainId, + address: params.stToken, + abi: dtfIndexStakingVaultAbi, + functionName: "redeem", + args: [params.shares, params.account, params.account] as const, + }); +} + /** Prepares a staking-vault reward claim call. */ export function prepareVoteLockClaimRewards(params: { readonly stToken: Address; diff --git a/packages/sdk/src/index-dtf/vote-lock/index.ts b/packages/sdk/src/index-dtf/vote-lock/index.ts index 47ea689..35e61af 100644 --- a/packages/sdk/src/index-dtf/vote-lock/index.ts +++ b/packages/sdk/src/index-dtf/vote-lock/index.ts @@ -40,6 +40,14 @@ export type VoteLockState = { readonly delegate: Address; readonly optimisticDelegate: Address | null; readonly maxWithdraw: Amount; + /** Vault share balance of the account. */ + readonly shareBalance: Amount; + /** + * Underlying assets per one share unit (`convertToAssets(10^decimals)`) — 1 for legacy 1:1 vaults, + * above 1 for self-appreciating vaults. Display-only: exact conversions must use + * `previewRedeem`/`maxWithdraw`, not rate multiplication (rounding). + */ + readonly exchangeRate: Amount; readonly optimisticVotingPower: Amount | null; readonly hasOptimisticVotingPower: boolean; readonly unstakingDelay: bigint; @@ -69,6 +77,8 @@ type VoteLockStateMulticallResults = readonly [ VoteLockMulticallResult
, VoteLockMulticallResult, VoteLockMulticallResult, + VoteLockMulticallResult, + VoteLockMulticallResult, VoteLockMulticallResult
, ]; @@ -207,6 +217,19 @@ async function readVoteLockState( functionName: "maxWithdraw", args: [account], }, + { + address: stToken, + abi: dtfIndexStakingVaultAbi, + functionName: "balanceOf", + args: [account], + }, + { + address: stToken, + abi: dtfIndexStakingVaultAbi, + functionName: "convertToAssets", + // StakingVault has no ERC4626 decimals offset, so share decimals == underlying decimals. + args: [10n ** BigInt(underlying.decimals)], + }, { address: stToken, abi: dtfIndexStakingVaultAbi, @@ -226,6 +249,8 @@ async function readVoteLockState( allowanceResult, delegateResult, maxWithdrawResult, + shareBalanceResult, + exchangeRateResult, unstakingDelayResult, unstakingManagerResult, ] = results; @@ -234,6 +259,8 @@ async function readVoteLockState( if (allowanceResult.status === "failure") throw allowanceResult.error; if (delegateResult.status === "failure") throw delegateResult.error; if (maxWithdrawResult.status === "failure") throw maxWithdrawResult.error; + if (shareBalanceResult.status === "failure") throw shareBalanceResult.error; + if (exchangeRateResult.status === "failure") throw exchangeRateResult.error; if (unstakingDelayResult.status === "failure") throw unstakingDelayResult.error; if (unstakingManagerResult.status === "failure") throw unstakingManagerResult.error; @@ -246,6 +273,8 @@ async function readVoteLockState( delegate: delegateResult.result, optimisticDelegate, maxWithdraw: mapAmount(maxWithdrawResult.result, underlying.decimals), + shareBalance: mapAmount(shareBalanceResult.result, underlying.decimals), + exchangeRate: mapAmount(exchangeRateResult.result, underlying.decimals), optimisticVotingPower: optimisticVotingPower === null ? null : mapAmount(optimisticVotingPower), hasOptimisticVotingPower: (optimisticVotingPower ?? 0n) > 0n, unstakingDelay: unstakingDelayResult.result, diff --git a/packages/sdk/src/index-dtf/vote-lock/namespace.ts b/packages/sdk/src/index-dtf/vote-lock/namespace.ts index 8a3819d..7acd674 100644 --- a/packages/sdk/src/index-dtf/vote-lock/namespace.ts +++ b/packages/sdk/src/index-dtf/vote-lock/namespace.ts @@ -51,6 +51,7 @@ import { prepareVoteLockDeposit, prepareVoteLockDepositPlan, prepareVoteLockPoke, + prepareVoteLockRedeem, prepareVoteLockRemoveRewardToken, prepareVoteLockSetRewardRatio, prepareVoteLockSetUnstakingDelay, @@ -141,6 +142,7 @@ export function createIndexDtfVoteLockNamespace(client: DtfClient) { prepareVoteLockDelegate, prepareVoteLockDelegateOptimistic, prepareVoteLockPoke, + prepareVoteLockRedeem, prepareVoteLockRemoveRewardToken, prepareVoteLockSetRewardRatio, prepareVoteLockSetUnstakingDelay, diff --git a/packages/sdk/src/index-dtf/vote-lock/ref.ts b/packages/sdk/src/index-dtf/vote-lock/ref.ts index 31b16a0..aafe8ba 100644 --- a/packages/sdk/src/index-dtf/vote-lock/ref.ts +++ b/packages/sdk/src/index-dtf/vote-lock/ref.ts @@ -52,6 +52,7 @@ import { prepareVoteLockDeposit, prepareVoteLockDepositPlan, prepareVoteLockPoke, + prepareVoteLockRedeem, prepareVoteLockRemoveRewardToken, prepareVoteLockSetRewardRatio, prepareVoteLockSetUnstakingDelay, @@ -160,6 +161,8 @@ export function createIndexDtfVoteLockRef(client: DtfClient, params: DtfParams) ) => prepareVoteLockDelegateOptimistic({ ...call, chainId }), prepareVoteLockPoke: (call: Omit[0], "chainId">) => prepareVoteLockPoke({ ...call, chainId }), + prepareVoteLockRedeem: (call: Omit[0], "chainId">) => + prepareVoteLockRedeem({ ...call, chainId }), prepareVoteLockRemoveRewardToken: (call: Omit[0], "chainId">) => prepareVoteLockRemoveRewardToken({ ...call, chainId }), prepareVoteLockSetRewardRatio: (call: Omit[0], "chainId">) => diff --git a/packages/sdk/src/index-dtf/vote-lock/vote-lock.test.ts b/packages/sdk/src/index-dtf/vote-lock/vote-lock.test.ts new file mode 100644 index 0000000..45a157f --- /dev/null +++ b/packages/sdk/src/index-dtf/vote-lock/vote-lock.test.ts @@ -0,0 +1,100 @@ +import type { PublicClient } from "viem"; + +import { describe, expect, it, vi } from "vitest"; + +import { createDtfClient } from "@/client"; +import { getVoteLockVaultState, prepareVoteLockRedeem } from "@/index-dtf/vote-lock/index"; + +const STTOKEN = "0x0000000000000000000000000000000000000001"; +const ACCOUNT = "0x0000000000000000000000000000000000000002"; +const UNDERLYING = "0x0000000000000000000000000000000000000003"; +const DELEGATE = "0x0000000000000000000000000000000000000004"; +const MANAGER = "0x0000000000000000000000000000000000000005"; + +describe("prepareVoteLockRedeem", () => { + it("builds a shares-denominated redeem call", () => { + const call = prepareVoteLockRedeem({ + stToken: STTOKEN, + chainId: 56, + shares: 123n, + account: ACCOUNT, + }); + + expect(call.chainId).toBe(56); + expect(call.contract.address).toBe(STTOKEN); + expect(call.contract.functionName).toBe("redeem"); + expect(call.contract.args).toEqual([123n, ACCOUNT, ACCOUNT]); + }); +}); + +describe("getVoteLockVaultState", () => { + it("maps shareBalance and exchangeRate from the state multicall", async () => { + const success = (result: T) => ({ status: "success" as const, result }); + const readContract = vi.fn(async () => UNDERLYING); + const multicall = vi.fn(async ({ contracts }: { contracts: readonly { functionName: string }[] }) => { + if (contracts[0]?.functionName === "name") { + return ["Reserve Rights", "RSR", 18]; + } + + if (contracts[0]?.functionName === "optimisticDelegates") { + return [success(DELEGATE), success(0n)]; + } + + expect(contracts.map((contract) => contract.functionName)).toEqual([ + "balanceOf", + "allowance", + "delegates", + "maxWithdraw", + "balanceOf", + "convertToAssets", + "unstakingDelay", + "unstakingManager", + ]); + + return [ + success(1000n * 10n ** 18n), // underlying balanceOf + success(0n), // allowance + success(DELEGATE), // delegates + success(101_875n * 10n ** 16n), // maxWithdraw = 1018.75e18 + success(1000n * 10n ** 18n), // vault balanceOf (shares) + success(101_875n * 10n ** 13n), // convertToAssets(1e18) = 1.01875e18 + success(1209600n), // unstakingDelay + success(MANAGER), // unstakingManager + ]; + }); + const base = createDtfClient({ + chains: { + 56: { + publicClient: { + readContract, + multicall, + } as unknown as PublicClient, + }, + }, + }); + const client = { + ...base, + api: { ...base.api, getTokenPrices: vi.fn(async () => []) }, + }; + + const state = await getVoteLockVaultState(client, { + chainId: 56, + stToken: STTOKEN, + account: ACCOUNT, + }); + + expect(state.shareBalance).toEqual({ raw: 1000n * 10n ** 18n, formatted: "1000" }); + expect(state.exchangeRate.raw).toBe(101_875n * 10n ** 13n); + expect(state.exchangeRate.formatted).toBe("1.01875"); + expect(state.maxWithdraw.raw).toBe(101_875n * 10n ** 16n); + + const stateCall = multicall.mock.calls.find(([{ contracts }]) => + contracts.some((contract) => contract.functionName === "convertToAssets"), + ); + expect(stateCall).toBeDefined(); + const convertToAssets = stateCall?.[0].contracts.find((contract) => contract.functionName === "convertToAssets") as + | { args?: readonly bigint[] } + | undefined; + expect(convertToAssets?.args).toEqual([10n ** 18n]); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 9ddcb40..099ad33 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -323,6 +323,7 @@ export { prepareVoteLockRemoveRewardToken, prepareVoteLockSetRewardRatio, prepareVoteLockSetUnstakingDelay, + prepareVoteLockRedeem, prepareVoteLockWithdraw, resolveIndexDtfAlias, selectorRegistryAbi,