From 6bd2e17c2f96267ac01fd4d26046c66a6201feb1 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 14 Jul 2026 15:57:03 -0700 Subject: [PATCH 01/19] fix(governance): strict-majority tie semantics + yield list state derivation; test(rebalance): unmocked zero-price/supply + golden openAuction calldata --- .changeset/strict-tie-semantics.md | 5 + docs/wiki/domains/sdk.md | 3 +- docs/wiki/log.md | 7 +- docs/wiki/progress.md | 17 +- .../src/index-dtf/governance/utils.test.ts | 71 ++++++ .../sdk/src/index-dtf/governance/utils.ts | 7 +- .../open-auction.integration.test.ts | 108 +++++++++ packages/sdk/src/index.ts | 1 + packages/sdk/src/types/yield-dtf.ts | 3 +- packages/sdk/src/yield-dtf/governance.test.ts | 220 +++++++++++++++++- packages/sdk/src/yield-dtf/governance.ts | 62 ++++- packages/sdk/src/yield-dtf/index.ts | 1 + 12 files changed, 489 insertions(+), 16 deletions(-) create mode 100644 .changeset/strict-tie-semantics.md create mode 100644 packages/sdk/src/index-dtf/governance/utils.test.ts create mode 100644 packages/sdk/src/index-dtf/rebalance/open-auction.integration.test.ts diff --git a/.changeset/strict-tie-semantics.md b/.changeset/strict-tie-semantics.md new file mode 100644 index 0000000..ea107e6 --- /dev/null +++ b/.changeset/strict-tie-semantics.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": patch +--- + +Match OZ Governor strict-majority semantics in Index DTF proposal state derivation (a for/against tie is now DEFEATED, not SUCCEEDED) and derive lagging Yield DTF proposal list states (ACTIVE past the vote deadline now resolves to DEFEATED/QUORUM_NOT_REACHED/SUCCEEDED) via the new getYieldDtfProposalState. diff --git a/docs/wiki/domains/sdk.md b/docs/wiki/domains/sdk.md index a978d73..0503827 100644 --- a/docs/wiki/domains/sdk.md +++ b/docs/wiki/domains/sdk.md @@ -1,6 +1,6 @@ --- title: Core SDK Domain -updated: 2026-07-09 +updated: 2026-07-14 type: domain sources: - packages/sdk/src/** @@ -22,6 +22,7 @@ sources: ## Invariants - On-chain integer amounts are `Amount`; display-class values may be numbers. +- Proposal vote success is OZ strict majority: a for/against tie is DEFEATED. Subgraph proposal state lags time-based transitions, so list/detail state surfaces derive from votes, quorum, and deadline instead of returning the raw field. - Index proposal IDs are globally unique and do not need DTF-membership checks. - Public call builders require exact calldata and value assertions when changed. - GraphQL document changes must regenerate and diff committed generated outputs. diff --git a/docs/wiki/log.md b/docs/wiki/log.md index f22a57c..af9c07e 100644 --- a/docs/wiki/log.md +++ b/docs/wiki/log.md @@ -1,6 +1,6 @@ --- title: Log -updated: 2026-07-09 +updated: 2026-07-14 type: log --- @@ -18,3 +18,8 @@ Append-only chronological record: lessons, corrections, friction. Newest section - Closeout review hardened the workflow against three false-green cases: GraphQL freshness now compares codegen output to the pre-run working tree, public Vocs routes are validated as routes, and full gates force builds instead of trusting Turbo cache. The upstream workflow installer preserves project-owned attributes while adding wiki union merges; wiki-lint rejects duplicate merge artifacts. All 24 upstream tests pass. - Current-DTF integration added platform fee and status to the aggregate while preserving focused hooks, moved rebalance liquidity/Ondo health into core plus React, and removed the duplicated Register route/API/calculation paths. Preserve-modules packaging reduced the traced price reader from 538.08 kB to 15.41 kB (5.12 kB gzip); the bundle gate excludes Zod, rebalance-lib, and Decimal from that path. - Workflow evaluation was useful: the red-flag scan exposed broad `any` in a table-driven React SDK test, and the gate caught formatting before review. Preserve-modules build reporting then flooded and truncated verification output, so SDK per-file reports were disabled and the dedicated bundle assertion was added to scoped/full gates. The full high-profile loop remained fast enough; no kit boundary change is justified. + +## 2026-07-14 + +- Governance hardening (Register audit Z18/Z22 follow-through): Index `getProposalState` treated a for/against tie as SUCCEEDED; both FolioGovernor (OZ 5.1.0 GovernorCountingSimpleUpgradeable) and Yield Governance.sol (vendored GovernorCountingSimple) require forVotes strictly over againstVotes, so ties now resolve DEFEATED. Yield proposal lists previously returned raw subgraph state, which lags time-based transitions; `getYieldDtfProposalState` now derives PENDING/ACTIVE resolution summary-level (bigint votes, quorum, native timepoint), fetching the block number only when a non-terminal Alexios proposal needs it. Lesson: subgraph state is event-driven everywhere — any list surface showing proposal state needs a derivation, not the raw field. +- The open-auction builder tests fully mocked dtf-rebalance-lib, so nothing proved its zero-price/zero-supply guards surfaced through `prepareIndexDtfOpenAuctionArgs`. An unmocked integration spec now pins those throws and one golden exact-calldata fixture for a fixed two-token rebalance snapshot. Lib errors intentionally pass through raw (no SdkError wrap): messages like "auction launcher MUST closeRebalance" carry operator instructions that must not be reshaped. diff --git a/docs/wiki/progress.md b/docs/wiki/progress.md index d295abc..b3ba4ed 100644 --- a/docs/wiki/progress.md +++ b/docs/wiki/progress.md @@ -1,6 +1,6 @@ --- title: Progress -updated: 2026-07-09 +updated: 2026-07-14 type: ledger --- @@ -8,12 +8,17 @@ 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 | -| ------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| 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 | +| ----------------------------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| 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 + +- `getYieldDtfProposalState` labels zero-engagement PENDING proposals past `voteEnd` as EXPIRED, matching Register's `getProposalStatus` and the Index derivation; strict OZ semantics would compute the vote outcome instead. Product decision whether to change all three surfaces together. +- Yield detail (`getYieldDtfProposal`) trusts on-chain `governor.state()`, which reports failed-quorum proposals as DEFEATED while the list derivation distinguishes QUORUM_NOT_REACHED. Decide whether detail should layer the display distinction. +- `getYieldDtfProposalState(proposal, currentTimepoint)` relies on the caller passing the governor-native unit (seconds vs blocks); consider an explicit `isTimepointBased` param like `getYieldDtfProposalVotePower` if external adoption grows. diff --git a/packages/sdk/src/index-dtf/governance/utils.test.ts b/packages/sdk/src/index-dtf/governance/utils.test.ts new file mode 100644 index 0000000..57f120e --- /dev/null +++ b/packages/sdk/src/index-dtf/governance/utils.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { getProposalState } from "@/index-dtf/governance/utils"; + +const ENDED_STANDARD_PROPOSAL = { + state: "ACTIVE", + voteStart: 100, + voteEnd: 200, + forWeightedVotes: { raw: 0n, formatted: "0" }, + againstWeightedVotes: { raw: 0n, formatted: "0" }, + abstainWeightedVotes: { raw: 0n, formatted: "0" }, + quorumVotes: { raw: 100n, formatted: "100" }, +} as const; + +describe("getProposalState standard vote outcomes", () => { + it("defeats a tie even when quorum is met", () => { + expect( + getProposalState( + { + ...ENDED_STANDARD_PROPOSAL, + forWeightedVotes: { raw: 150n, formatted: "150" }, + againstWeightedVotes: { raw: 150n, formatted: "150" }, + }, + 201, + ), + ).toMatchObject({ state: "DEFEATED" }); + }); + + it("succeeds when for votes are one wei over against votes", () => { + expect( + getProposalState( + { + ...ENDED_STANDARD_PROPOSAL, + forWeightedVotes: { raw: 151n, formatted: "151" }, + againstWeightedVotes: { raw: 150n, formatted: "150" }, + }, + 201, + ), + ).toMatchObject({ state: "SUCCEEDED" }); + }); + + it("defeats ended proposals with zero for votes", () => { + expect(getProposalState(ENDED_STANDARD_PROPOSAL, 201)).toMatchObject({ state: "DEFEATED" }); + }); + + it("keeps proposals active at the vote end boundary", () => { + expect( + getProposalState( + { + ...ENDED_STANDARD_PROPOSAL, + forWeightedVotes: { raw: 150n, formatted: "150" }, + againstWeightedVotes: { raw: 150n, formatted: "150" }, + }, + 200, + ), + ).toMatchObject({ state: "ACTIVE" }); + }); + + it("marks winning votes under quorum as quorum not reached", () => { + expect( + getProposalState( + { + ...ENDED_STANDARD_PROPOSAL, + forWeightedVotes: { raw: 50n, formatted: "50" }, + againstWeightedVotes: { raw: 10n, formatted: "10" }, + }, + 201, + ), + ).toMatchObject({ state: "QUORUM_NOT_REACHED" }); + }); +}); diff --git a/packages/sdk/src/index-dtf/governance/utils.ts b/packages/sdk/src/index-dtf/governance/utils.ts index bfe4ffd..c8f775e 100644 --- a/packages/sdk/src/index-dtf/governance/utils.ts +++ b/packages/sdk/src/index-dtf/governance/utils.ts @@ -143,10 +143,9 @@ export function getProposalState(proposal: ProposalVoteStateInput, timestamp = g } else if (timestamp > proposal.voteEnd) { if (isOptimistic) { state.state = getOptimisticFinalState(proposal, optimisticVetoThresholdVotes); - } else if ( - proposal.againstWeightedVotes.raw > proposal.forWeightedVotes.raw || - proposal.forWeightedVotes.raw === 0n - ) { + } else if (proposal.forWeightedVotes.raw <= proposal.againstWeightedVotes.raw) { + // WHY: OZ GovernorCountingSimple._voteSucceeded requires forVotes STRICTLY + // over againstVotes (FolioGovernor does not override it) — a tie is defeated. state.state = "DEFEATED"; } else if (proposal.forWeightedVotes.raw + proposal.abstainWeightedVotes.raw < quorumVotes) { state.state = "QUORUM_NOT_REACHED"; diff --git a/packages/sdk/src/index-dtf/rebalance/open-auction.integration.test.ts b/packages/sdk/src/index-dtf/rebalance/open-auction.integration.test.ts new file mode 100644 index 0000000..2ed642e --- /dev/null +++ b/packages/sdk/src/index-dtf/rebalance/open-auction.integration.test.ts @@ -0,0 +1,108 @@ +import { PriceControl } from "@reserve-protocol/dtf-rebalance-lib"; +import { describe, expect, it } from "vitest"; + +import type { IndexDtfOpenAuctionInput } from "@/index-dtf/rebalance/types"; + +import { prepareIndexDtfOpenAuction, prepareIndexDtfOpenAuctionArgs } from "@/index-dtf/rebalance/open-auction"; + +// Runs the REAL dtf-rebalance-lib (unlike open-auction.test.ts, which mocks it) +// so its zero-price/zero-supply guards provably surface through the SDK builder +// instead of producing silently skewed auction args. + +const DTF = "0x0000000000000000000000000000000000000D7F"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; +const USDC_KEY = USDC.toLowerCase(); +const DAI_KEY = DAI.toLowerCase(); + +const USDC_WEIGHT = { low: 45n * 10n ** 13n, spot: 5n * 10n ** 14n, high: 55n * 10n ** 13n } as const; +const DAI_WEIGHT = { low: 45n * 10n ** 25n, spot: 5n * 10n ** 26n, high: 55n * 10n ** 25n } as const; + +describe("prepareIndexDtfOpenAuctionArgs with the real rebalance library", () => { + it("throws on a zero-price token instead of building skewed auction args", () => { + const input = createInput(); + + expect(() => + prepareIndexDtfOpenAuctionArgs({ + ...input, + prices: { ...input.prices, [USDC_KEY]: { currentPrice: 0, snapshotPrice: 1 } }, + }), + ).toThrow(/missing price for token/); + }); + + it("throws on a zero-price token in the target basket price set", () => { + const input = createInput(); + + expect(() => + prepareIndexDtfOpenAuctionArgs({ + ...input, + initialPrices: { ...input.initialPrices, [DAI_KEY]: 0 }, + }), + ).toThrow(/missing price for token index/); + }); + + it("throws on zero supply instead of building skewed auction args", () => { + expect(() => prepareIndexDtfOpenAuctionArgs({ ...createInput(), supply: 0n })).toThrow(/Division by zero/); + }); + + it("builds exact openAuction calldata for a fixed rebalance snapshot", () => { + const built = prepareIndexDtfOpenAuctionArgs(createInput()); + const call = prepareIndexDtfOpenAuction({ address: DTF, chainId: 1, args: built.args }); + + expect(call.to).toBe(DTF); + expect(call.data).toBe( + "0x3c46570f000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000d2f13f7789f00000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000e92596fd62900000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000001c6bf526340000000000000000000000000000000000000000000000000000001c6bf526340000000000000000000000000000000000000000000000000000001c6bf526340000000000000000000000000000000000000000000019d971e4fe8401e740000000000000000000000000000000000000000000000019d971e4fe8401e740000000000000000000000000000000000000000000000019d971e4fe8401e740000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000b5c0e8d21d902d61fa0000000000000000000000000000000000000000000000de24aac7eb3e705b4e00000000000000000000000000000000000000000000000000000000c7d713b49da00000000000000000000000000000000000000000000000000000f43fc2c04ee0000", + ); + }); +}); + +// 1000 whole shares fully in USDC at $1, rebalancing toward a 50/50 USDC/DAI +// basket — every value fixed so the built calldata is deterministic. +function createInput(): IndexDtfOpenAuctionInput { + return { + rebalance: { + nonce: 3n, + priceControl: PriceControl.NONE, + tokens: [ + { + token: USDC, + weight: USDC_WEIGHT, + price: { low: 9n * 10n ** 29n, high: 11n * 10n ** 29n }, + maxAuctionSize: 10n ** 12n, + inRebalance: true, + }, + { + token: DAI, + weight: DAI_WEIGHT, + price: { low: 9n * 10n ** 17n, high: 11n * 10n ** 17n }, + maxAuctionSize: 10n ** 24n, + inRebalance: true, + }, + ], + limits: { low: 9n * 10n ** 17n, spot: 10n ** 18n, high: 11n * 10n ** 17n }, + timestamps: { + startedAt: 1_700_000_000n, + restrictedUntil: 1_700_003_600n, + availableUntil: 1_700_007_200n, + }, + bidsEnabled: true, + }, + tokens: [ + { address: USDC, name: "USD Coin", symbol: "USDC", decimals: 6 }, + { address: DAI, name: "Dai Stablecoin", symbol: "DAI", decimals: 18 }, + ], + supply: 1_000n * 10n ** 18n, + initialSupply: 1_000n * 10n ** 18n, + currentAssets: { [USDC_KEY]: 1_000n * 10n ** 6n, [DAI_KEY]: 0n }, + initialAssets: { [USDC_KEY]: 1_000n * 10n ** 6n, [DAI_KEY]: 0n }, + initialPrices: { [USDC_KEY]: 1, [DAI_KEY]: 1 }, + initialWeights: { [USDC_KEY]: USDC_WEIGHT, [DAI_KEY]: DAI_WEIGHT }, + prices: { + [USDC_KEY]: { currentPrice: 1, snapshotPrice: 1 }, + [DAI_KEY]: { currentPrice: 1, snapshotPrice: 1 }, + }, + tokenPriceVolatility: { [USDC_KEY]: 0.01, [DAI_KEY]: 0.01 }, + rebalancePercent: 90, + isTrackingDtf: false, + }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8d78cd6..e76f644 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -350,6 +350,7 @@ export { getYieldDtfGovernance, getYieldDtfProposal, getYieldDtfProposals, + getYieldDtfProposalState, getYieldDtfProposalVotePower, getYieldDtfRevenue, getYieldDtfApy, diff --git a/packages/sdk/src/types/yield-dtf.ts b/packages/sdk/src/types/yield-dtf.ts index b7f3bc3..a7d77b3 100644 --- a/packages/sdk/src/types/yield-dtf.ts +++ b/packages/sdk/src/types/yield-dtf.ts @@ -265,7 +265,8 @@ export type YieldDtfProposalState = | "SUCCEEDED" | "QUEUED" | "EXPIRED" - | "EXECUTED"; + | "EXECUTED" + | "QUORUM_NOT_REACHED"; export type YieldDtfProposalSummary = { readonly id: string; diff --git a/packages/sdk/src/yield-dtf/governance.test.ts b/packages/sdk/src/yield-dtf/governance.test.ts index 79a29b5..dc42445 100644 --- a/packages/sdk/src/yield-dtf/governance.test.ts +++ b/packages/sdk/src/yield-dtf/governance.test.ts @@ -3,7 +3,12 @@ import { describe, expect, it, vi } from "vitest"; import type { DtfClient } from "@/client"; -import { getYieldDtfGovernance, getYieldDtfProposal } from "@/yield-dtf/governance"; +import { + getYieldDtfGovernance, + getYieldDtfProposal, + getYieldDtfProposals, + getYieldDtfProposalState, +} from "@/yield-dtf/governance"; const DTF = "0x0000000000000000000000000000000000000001"; const GOVERNOR = "0x0000000000000000000000000000000000000002"; @@ -104,6 +109,219 @@ describe("getYieldDtfProposal", () => { }); }); +describe("getYieldDtfProposalState", () => { + const endedActiveProposal = { + state: "ACTIVE", + voteStart: 100, + voteEnd: 200, + forWeightedVotes: { raw: 0n, formatted: "0" }, + againstWeightedVotes: { raw: 0n, formatted: "0" }, + abstainWeightedVotes: { raw: 0n, formatted: "0" }, + quorumVotes: { raw: 100n, formatted: "100" }, + } as const; + + it("defeats a tie even when quorum is met", () => { + expect( + getYieldDtfProposalState( + { + ...endedActiveProposal, + forWeightedVotes: { raw: 150n, formatted: "150" }, + againstWeightedVotes: { raw: 150n, formatted: "150" }, + }, + 201, + ), + ).toBe("DEFEATED"); + }); + + it("succeeds when for votes are one wei over against votes", () => { + expect( + getYieldDtfProposalState( + { + ...endedActiveProposal, + forWeightedVotes: { raw: 151n, formatted: "151" }, + againstWeightedVotes: { raw: 150n, formatted: "150" }, + }, + 201, + ), + ).toBe("SUCCEEDED"); + }); + + it("marks winning votes under quorum as quorum not reached", () => { + expect( + getYieldDtfProposalState( + { + ...endedActiveProposal, + forWeightedVotes: { raw: 50n, formatted: "50" }, + againstWeightedVotes: { raw: 10n, formatted: "10" }, + }, + 201, + ), + ).toBe("QUORUM_NOT_REACHED"); + }); + + it("keeps active proposals active until the vote deadline passes", () => { + expect(getYieldDtfProposalState(endedActiveProposal, 200)).toBe("ACTIVE"); + }); + + it("activates lagging pending proposals and expires missed ones", () => { + const pending = { ...endedActiveProposal, state: "PENDING" } as const; + + expect(getYieldDtfProposalState(pending, 50)).toBe("PENDING"); + expect(getYieldDtfProposalState(pending, 100)).toBe("PENDING"); + expect(getYieldDtfProposalState(pending, 150)).toBe("ACTIVE"); + expect(getYieldDtfProposalState(pending, 200)).toBe("ACTIVE"); + expect(getYieldDtfProposalState(pending, 201)).toBe("EXPIRED"); + }); + + it("keeps terminal states untouched", () => { + expect(getYieldDtfProposalState({ ...endedActiveProposal, state: "EXECUTED" }, 201)).toBe("EXECUTED"); + }); +}); + +describe("getYieldDtfProposals", () => { + it("resolves lagging timestamp-based proposals without reading the block number", async () => { + const getBlockNumber = vi.fn(async () => 1_000n); + const client = createProposalsClient( + [ + createSubgraphProposal({ + id: "tie", + state: "ACTIVE", + forWeightedVotes: "150", + againstWeightedVotes: "150", + endBlock: "200", + }), + ], + getBlockNumber, + ); + + const [proposal] = await getYieldDtfProposals(client, { address: DTF, chainId: 1 }); + + expect(proposal!.state).toBe("DEFEATED"); + expect(getBlockNumber).not.toHaveBeenCalled(); + }); + + it("resolves lagging block-based proposals against the current block number", async () => { + const getBlockNumber = vi.fn(async () => 1_000n); + const client = createProposalsClient( + [ + createSubgraphProposal({ + id: "succeeded", + state: "ACTIVE", + governorName: "Governor Alexios", + forWeightedVotes: "150", + againstWeightedVotes: "10", + endBlock: "999", + }), + createSubgraphProposal({ + id: "still-active", + state: "ACTIVE", + governorName: "Governor Alexios", + forWeightedVotes: "150", + againstWeightedVotes: "10", + endBlock: "1001", + }), + ], + getBlockNumber, + ); + + const proposals = await getYieldDtfProposals(client, { address: DTF, chainId: 1 }); + + expect(proposals.map((proposal) => proposal.state)).toEqual(["SUCCEEDED", "ACTIVE"]); + expect(getBlockNumber).toHaveBeenCalledTimes(1); + }); + + it("derives mixed governor flavors in one list with their native timepoints", async () => { + const getBlockNumber = vi.fn(async () => 1_000n); + const client = createProposalsClient( + [ + createSubgraphProposal({ + id: "anastasius-tie", + state: "ACTIVE", + forWeightedVotes: "150", + againstWeightedVotes: "150", + endBlock: "200", + }), + createSubgraphProposal({ + id: "alexios-still-active", + state: "ACTIVE", + governorName: "Governor Alexios", + forWeightedVotes: "150", + againstWeightedVotes: "10", + endBlock: "1001", + }), + ], + getBlockNumber, + ); + + const proposals = await getYieldDtfProposals(client, { address: DTF, chainId: 1 }); + + expect(proposals.map((proposal) => proposal.state)).toEqual(["DEFEATED", "ACTIVE"]); + expect(getBlockNumber).toHaveBeenCalledTimes(1); + }); + + it("does not read the block number when block-based proposals are terminal", async () => { + const getBlockNumber = vi.fn(async () => 1_000n); + const client = createProposalsClient( + [ + createSubgraphProposal({ + id: "executed", + state: "EXECUTED", + governorName: "Governor Alexios", + endBlock: "999", + }), + ], + getBlockNumber, + ); + + const [proposal] = await getYieldDtfProposals(client, { address: DTF, chainId: 1 }); + + expect(proposal!.state).toBe("EXECUTED"); + expect(getBlockNumber).not.toHaveBeenCalled(); + }); +}); + +function createSubgraphProposal(overrides: { + readonly id: string; + readonly state: string; + readonly governorName?: string; + readonly forWeightedVotes?: string; + readonly againstWeightedVotes?: string; + readonly endBlock?: string; +}) { + return { + id: overrides.id, + description: "Proposal", + creationTime: "1000", + state: overrides.state, + forWeightedVotes: overrides.forWeightedVotes ?? "0", + againstWeightedVotes: overrides.againstWeightedVotes ?? "0", + abstainWeightedVotes: "0", + quorumVotes: "100", + startBlock: "100", + endBlock: overrides.endBlock ?? "200", + executionETA: null, + proposer: { address: PROPOSER }, + governanceFramework: { + name: overrides.governorName ?? "Governor Anastasius", + contractAddress: GOVERNOR, + }, + }; +} + +function createProposalsClient( + proposals: readonly ReturnType[], + getBlockNumber: () => Promise, +): DtfClient { + return { + subgraph: { + queryYield: vi.fn(async () => ({ proposals })), + }, + viem: { + getPublicClient: vi.fn(() => ({ getBlockNumber })), + }, + } as unknown as DtfClient; +} + function createProposalClient(readError: Error): DtfClient { return { subgraph: { diff --git a/packages/sdk/src/yield-dtf/governance.ts b/packages/sdk/src/yield-dtf/governance.ts index c4ec3f6..5ee5f99 100644 --- a/packages/sdk/src/yield-dtf/governance.ts +++ b/packages/sdk/src/yield-dtf/governance.ts @@ -23,7 +23,7 @@ import { prepareTimelockCancel, type GovernorProposalPayload, } from "@/lib/governor-calls"; -import { mapAmount, sameAddress } from "@/lib/utils"; +import { getCurrentTime, mapAmount, sameAddress } from "@/lib/utils"; import { yieldGovernanceAbi } from "@/yield-dtf/abis/governance"; import { yieldGovernanceAnastasiusAbi } from "@/yield-dtf/abis/governance-anastasius"; import { stRsrVotesAbi } from "@/yield-dtf/abis/st-rsr-votes"; @@ -151,7 +151,65 @@ export async function getYieldDtfProposals( }, }); - return proposals.map((proposal) => mapProposalSummary(proposal, params.chainId)); + const summaries = proposals.map((proposal) => mapProposalSummary(proposal, params.chainId)); + const needsBlockNumber = summaries.some( + (summary) => + (summary.state === "PENDING" || summary.state === "ACTIVE") && !isTimepointGovernor(summary.governorName), + ); + const blockNumber = needsBlockNumber ? Number(await client.viem.getPublicClient(params.chainId).getBlockNumber()) : 0; + + return summaries.map((summary) => ({ + ...summary, + state: getYieldDtfProposalState( + summary, + isTimepointGovernor(summary.governorName) ? getCurrentTime() : blockNumber, + ), + })); +} + +/** + * Derives the summary display state without an on-chain read. The subgraph + * state is event-driven and lags time-based transitions (PENDING -> ACTIVE, + * ACTIVE -> DEFEATED/QUORUM_NOT_REACHED/SUCCEEDED). `currentTimepoint` must be + * in the proposal's native unit: unix seconds for Anastasius, block number for + * Alexios. + */ +export function getYieldDtfProposalState( + proposal: Pick< + YieldDtfProposalSummary, + | "abstainWeightedVotes" + | "againstWeightedVotes" + | "forWeightedVotes" + | "quorumVotes" + | "state" + | "voteEnd" + | "voteStart" + >, + currentTimepoint: number, +): YieldDtfProposalState { + if (proposal.state === "PENDING") { + if (currentTimepoint > proposal.voteEnd) { + return "EXPIRED"; + } + + return currentTimepoint > proposal.voteStart ? "ACTIVE" : "PENDING"; + } + + if (proposal.state !== "ACTIVE" || currentTimepoint <= proposal.voteEnd) { + return proposal.state; + } + + // WHY: OZ GovernorCountingSimple._voteSucceeded requires forVotes STRICTLY + // over againstVotes (Reserve's Governance.sol keeps it) — a tie is defeated. + if (proposal.forWeightedVotes.raw <= proposal.againstWeightedVotes.raw) { + return "DEFEATED"; + } + + if (proposal.forWeightedVotes.raw + proposal.abstainWeightedVotes.raw < proposal.quorumVotes.raw) { + return "QUORUM_NOT_REACHED"; + } + + return "SUCCEEDED"; } export type GetYieldDtfProposalParams = { diff --git a/packages/sdk/src/yield-dtf/index.ts b/packages/sdk/src/yield-dtf/index.ts index 27bd03d..2df5124 100644 --- a/packages/sdk/src/yield-dtf/index.ts +++ b/packages/sdk/src/yield-dtf/index.ts @@ -118,6 +118,7 @@ export { getYieldDtfGovernance, getYieldDtfProposal, getYieldDtfProposals, + getYieldDtfProposalState, getYieldDtfProposalVotePower, getYieldDtfVoterState, isTimepointGovernor, From 6950a89d188e7cf1aab7d80b811e0de2d304e16b Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 14 Jul 2026 16:02:49 -0700 Subject: [PATCH 02/19] fix(governance): derive stale-PENDING outcomes past deadline; chain timepoint over wall clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a PENDING proposal past its deadline resolves to the vote/quorum outcome like stale ACTIVE (OZ Governor.state() semantics) — never EXPIRED - getYieldDtfProposals reads one latest block per request and derives Anastasius from block.timestamp, Alexios from block.number; the consumer machine's clock is never consulted --- packages/sdk/src/yield-dtf/governance.test.ts | 78 ++++++++++++++----- packages/sdk/src/yield-dtf/governance.ts | 41 ++++++---- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/packages/sdk/src/yield-dtf/governance.test.ts b/packages/sdk/src/yield-dtf/governance.test.ts index dc42445..7e02f92 100644 --- a/packages/sdk/src/yield-dtf/governance.test.ts +++ b/packages/sdk/src/yield-dtf/governance.test.ts @@ -163,14 +163,48 @@ describe("getYieldDtfProposalState", () => { expect(getYieldDtfProposalState(endedActiveProposal, 200)).toBe("ACTIVE"); }); - it("activates lagging pending proposals and expires missed ones", () => { + it("activates lagging pending proposals within the vote window", () => { const pending = { ...endedActiveProposal, state: "PENDING" } as const; expect(getYieldDtfProposalState(pending, 50)).toBe("PENDING"); expect(getYieldDtfProposalState(pending, 100)).toBe("PENDING"); expect(getYieldDtfProposalState(pending, 150)).toBe("ACTIVE"); expect(getYieldDtfProposalState(pending, 200)).toBe("ACTIVE"); - expect(getYieldDtfProposalState(pending, 201)).toBe("EXPIRED"); + }); + + it("derives the vote outcome for stale pending proposals past the deadline, never EXPIRED", () => { + const pending = { ...endedActiveProposal, state: "PENDING" } as const; + + expect( + getYieldDtfProposalState( + { + ...pending, + forWeightedVotes: { raw: 151n, formatted: "151" }, + againstWeightedVotes: { raw: 50n, formatted: "50" }, + }, + 201, + ), + ).toBe("SUCCEEDED"); + expect( + getYieldDtfProposalState( + { + ...pending, + forWeightedVotes: { raw: 150n, formatted: "150" }, + againstWeightedVotes: { raw: 150n, formatted: "150" }, + }, + 201, + ), + ).toBe("DEFEATED"); + expect( + getYieldDtfProposalState( + { + ...pending, + forWeightedVotes: { raw: 50n, formatted: "50" }, + againstWeightedVotes: { raw: 10n, formatted: "10" }, + }, + 201, + ), + ).toBe("QUORUM_NOT_REACHED"); }); it("keeps terminal states untouched", () => { @@ -179,8 +213,10 @@ describe("getYieldDtfProposalState", () => { }); describe("getYieldDtfProposals", () => { - it("resolves lagging timestamp-based proposals without reading the block number", async () => { - const getBlockNumber = vi.fn(async () => 1_000n); + it("derives timestamp-based proposals from the chain block timestamp, not the local clock", async () => { + // voteEnd is ~year 2286 by the machine's wall clock, but the chain says + // the deadline already passed — the chain must win. + const getBlock = vi.fn(async () => ({ number: 1_000n, timestamp: 20_000_000_000n })); const client = createProposalsClient( [ createSubgraphProposal({ @@ -188,20 +224,20 @@ describe("getYieldDtfProposals", () => { state: "ACTIVE", forWeightedVotes: "150", againstWeightedVotes: "150", - endBlock: "200", + endBlock: "9999999999", }), ], - getBlockNumber, + getBlock, ); const [proposal] = await getYieldDtfProposals(client, { address: DTF, chainId: 1 }); expect(proposal!.state).toBe("DEFEATED"); - expect(getBlockNumber).not.toHaveBeenCalled(); + expect(getBlock).toHaveBeenCalledTimes(1); }); - it("resolves lagging block-based proposals against the current block number", async () => { - const getBlockNumber = vi.fn(async () => 1_000n); + it("resolves lagging block-based proposals against the chain block number", async () => { + const getBlock = vi.fn(async () => ({ number: 1_000n, timestamp: 1_000_000n })); const client = createProposalsClient( [ createSubgraphProposal({ @@ -221,17 +257,17 @@ describe("getYieldDtfProposals", () => { endBlock: "1001", }), ], - getBlockNumber, + getBlock, ); const proposals = await getYieldDtfProposals(client, { address: DTF, chainId: 1 }); expect(proposals.map((proposal) => proposal.state)).toEqual(["SUCCEEDED", "ACTIVE"]); - expect(getBlockNumber).toHaveBeenCalledTimes(1); + expect(getBlock).toHaveBeenCalledTimes(1); }); - it("derives mixed governor flavors in one list with their native timepoints", async () => { - const getBlockNumber = vi.fn(async () => 1_000n); + it("derives mixed governor flavors in one list from a single chain read", async () => { + const getBlock = vi.fn(async () => ({ number: 1_000n, timestamp: 1_000_000n })); const client = createProposalsClient( [ createSubgraphProposal({ @@ -250,17 +286,17 @@ describe("getYieldDtfProposals", () => { endBlock: "1001", }), ], - getBlockNumber, + getBlock, ); const proposals = await getYieldDtfProposals(client, { address: DTF, chainId: 1 }); expect(proposals.map((proposal) => proposal.state)).toEqual(["DEFEATED", "ACTIVE"]); - expect(getBlockNumber).toHaveBeenCalledTimes(1); + expect(getBlock).toHaveBeenCalledTimes(1); }); - it("does not read the block number when block-based proposals are terminal", async () => { - const getBlockNumber = vi.fn(async () => 1_000n); + it("does not read the chain when every proposal is terminal", async () => { + const getBlock = vi.fn(async () => ({ number: 1_000n, timestamp: 1_000_000n })); const client = createProposalsClient( [ createSubgraphProposal({ @@ -270,13 +306,13 @@ describe("getYieldDtfProposals", () => { endBlock: "999", }), ], - getBlockNumber, + getBlock, ); const [proposal] = await getYieldDtfProposals(client, { address: DTF, chainId: 1 }); expect(proposal!.state).toBe("EXECUTED"); - expect(getBlockNumber).not.toHaveBeenCalled(); + expect(getBlock).not.toHaveBeenCalled(); }); }); @@ -310,14 +346,14 @@ function createSubgraphProposal(overrides: { function createProposalsClient( proposals: readonly ReturnType[], - getBlockNumber: () => Promise, + getBlock: () => Promise<{ number: bigint; timestamp: bigint }>, ): DtfClient { return { subgraph: { queryYield: vi.fn(async () => ({ proposals })), }, viem: { - getPublicClient: vi.fn(() => ({ getBlockNumber })), + getPublicClient: vi.fn(() => ({ getBlock })), }, } as unknown as DtfClient; } diff --git a/packages/sdk/src/yield-dtf/governance.ts b/packages/sdk/src/yield-dtf/governance.ts index 5ee5f99..1ab9834 100644 --- a/packages/sdk/src/yield-dtf/governance.ts +++ b/packages/sdk/src/yield-dtf/governance.ts @@ -23,7 +23,7 @@ import { prepareTimelockCancel, type GovernorProposalPayload, } from "@/lib/governor-calls"; -import { getCurrentTime, mapAmount, sameAddress } from "@/lib/utils"; +import { mapAmount, sameAddress } from "@/lib/utils"; import { yieldGovernanceAbi } from "@/yield-dtf/abis/governance"; import { yieldGovernanceAnastasiusAbi } from "@/yield-dtf/abis/governance-anastasius"; import { stRsrVotesAbi } from "@/yield-dtf/abis/st-rsr-votes"; @@ -152,18 +152,22 @@ export async function getYieldDtfProposals( }); const summaries = proposals.map((proposal) => mapProposalSummary(proposal, params.chainId)); - const needsBlockNumber = summaries.some( - (summary) => - (summary.state === "PENDING" || summary.state === "ACTIVE") && !isTimepointGovernor(summary.governorName), + // Proposal state is live chain state: one latest block serves both clock + // domains (timestamp for Anastasius, number for Alexios), fetched once per + // request and never the consumer machine's wall clock. + const needsTimepoint = summaries.some( + (summary) => summary.state === "PENDING" || summary.state === "ACTIVE", ); - const blockNumber = needsBlockNumber ? Number(await client.viem.getPublicClient(params.chainId).getBlockNumber()) : 0; + const block = needsTimepoint ? await client.viem.getPublicClient(params.chainId).getBlock() : undefined; return summaries.map((summary) => ({ ...summary, - state: getYieldDtfProposalState( - summary, - isTimepointGovernor(summary.governorName) ? getCurrentTime() : blockNumber, - ), + state: block + ? getYieldDtfProposalState( + summary, + isTimepointGovernor(summary.governorName) ? Number(block.timestamp) : Number(block.number), + ) + : summary.state, })); } @@ -187,17 +191,22 @@ export function getYieldDtfProposalState( >, currentTimepoint: number, ): YieldDtfProposalState { - if (proposal.state === "PENDING") { - if (currentTimepoint > proposal.voteEnd) { - return "EXPIRED"; + if (proposal.state !== "PENDING" && proposal.state !== "ACTIVE") { + return proposal.state; + } + + if (currentTimepoint <= proposal.voteEnd) { + if (proposal.state === "PENDING") { + return currentTimepoint > proposal.voteStart ? "ACTIVE" : "PENDING"; } - return currentTimepoint > proposal.voteStart ? "ACTIVE" : "PENDING"; + return "ACTIVE"; } - if (proposal.state !== "ACTIVE" || currentTimepoint <= proposal.voteEnd) { - return proposal.state; - } + // WHY: past the deadline OZ Governor.state() derives Succeeded/Defeated from + // the vote counts regardless of whether an ACTIVE transition was ever + // indexed — a stale PENDING is never EXPIRED (that is a queue-lifecycle + // state the subgraph reports directly). // WHY: OZ GovernorCountingSimple._voteSucceeded requires forVotes STRICTLY // over againstVotes (Reserve's Governance.sol keeps it) — a tie is defeated. From 102d0009aff2845e5f17a2fb5fd67aa9c4ddc99c Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 14 Jul 2026 16:33:25 -0700 Subject: [PATCH 03/19] fix(index-dtf): platform fee fails loud on zero denominator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zero denominator is an impossible fee tuple, not a 0% fee (numerator=0 over a positive denominator is the real 0%). Mapping it to percent:0 fabricated a confident 0% for an invalid registry response — the same class as a fabricated 50%. Now throws INVALID_RESPONSE so consumers render 'unavailable'. --- .../src/index-dtf/dtf/platform-fee.test.ts | 39 +++++++++++++++++++ .../sdk/src/index-dtf/dtf/platform-fee.ts | 14 ++++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/index-dtf/dtf/platform-fee.test.ts diff --git a/packages/sdk/src/index-dtf/dtf/platform-fee.test.ts b/packages/sdk/src/index-dtf/dtf/platform-fee.test.ts new file mode 100644 index 0000000..25ce022 --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/platform-fee.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DtfClient } from "@/client"; + +import { getIndexDtfPlatformFee } from "@/index-dtf/dtf/platform-fee"; +import { isSdkError } from "@/lib/errors"; + +const DTF = "0x0000000000000000000000000000000000000001"; +const REGISTRY = "0x0000000000000000000000000000000000000002"; +const RECIPIENT = "0x0000000000000000000000000000000000000003"; + +function createClient(feeDetails: readonly [string, bigint, bigint, bigint]): DtfClient { + const readContract = vi + .fn() + .mockResolvedValueOnce(REGISTRY) + .mockResolvedValueOnce(feeDetails); + return { viem: { readContract } } as unknown as DtfClient; +} + +describe("getIndexDtfPlatformFee", () => { + it("computes the percentage from a valid fee tuple", async () => { + const client = createClient([RECIPIENT, 50n, 100n, 0n]); + const fee = await getIndexDtfPlatformFee(client, { address: DTF, chainId: 1 }); + expect(fee.percent).toBe(50); + }); + + it("keeps a real 0% fee (zero numerator over a positive denominator)", async () => { + const client = createClient([RECIPIENT, 0n, 100n, 0n]); + const fee = await getIndexDtfPlatformFee(client, { address: DTF, chainId: 1 }); + expect(fee.percent).toBe(0); + }); + + it("fails loud on a zero denominator instead of fabricating 0%", async () => { + const client = createClient([RECIPIENT, 1n, 0n, 0n]); + const error = await getIndexDtfPlatformFee(client, { address: DTF, chainId: 1 }).catch((e) => e); + expect(isSdkError(error)).toBe(true); + expect(error.code).toBe("INVALID_RESPONSE"); + }); +}); diff --git a/packages/sdk/src/index-dtf/dtf/platform-fee.ts b/packages/sdk/src/index-dtf/dtf/platform-fee.ts index 9fb80f3..ae4d760 100644 --- a/packages/sdk/src/index-dtf/dtf/platform-fee.ts +++ b/packages/sdk/src/index-dtf/dtf/platform-fee.ts @@ -6,6 +6,7 @@ import type { IndexDtf, IndexDtfPlatformFee } from "@/types/index-dtf"; import { daoFeeRegistryAbi } from "@/index-dtf/abis/dao-fee-registry"; import { dtfIndexAbi } from "@/index-dtf/abis/dtf-index-abi"; import { Decimal } from "@/lib/decimal"; +import { SdkError } from "@/lib/errors"; /** Reads the DAO fee registry settings that affect this DTF's revenue split. */ export async function getIndexDtfPlatformFee( @@ -29,12 +30,23 @@ export async function getIndexDtfPlatformFee( blockNumber: params.blockNumber, }); + // A zero denominator is an impossible fee tuple, not a 0% fee (numerator=0, + // denominator>0 is the real 0%). Fail loud so consumers show "unavailable" + // rather than a confident fabricated 0%. + if (denominator === 0n) { + throw new SdkError({ + code: "INVALID_RESPONSE", + message: `DAO fee registry returned a zero denominator for ${address} on chain ${params.chainId}`, + meta: { chainId: params.chainId, entity: "indexDtfPlatformFee", address }, + }); + } + return { registry, recipient, numerator, denominator, floor, - percent: denominator === 0n ? 0 : new Decimal(numerator.toString()).mul(100).div(denominator.toString()).toNumber(), + percent: new Decimal(numerator.toString()).mul(100).div(denominator.toString()).toNumber(), }; } From b0bbbe1bba6ebdd3e3006cfa9589d661abab34f8 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 14 Jul 2026 16:58:31 -0700 Subject: [PATCH 04/19] feat(index-dtf): type the exposure API contract (ytd period, underlyingMarketCap, native/bridge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exposure endpoint (which Register consumes) returned ytd data, per-token underlyingMarketCap, and typed native/bridge metadata, but the SDK types declared period without ytd, omitted underlyingMarketCap, and left native/bridge as unknown — forcing consumers to cast. Types now match the response; fixture pins the fields. --- .../sdk/src/index-dtf/dtf/exposure.test.ts | 43 +++++++++++++++++++ packages/sdk/src/index-dtf/dtf/exposure.ts | 35 +++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/src/index-dtf/dtf/exposure.test.ts diff --git a/packages/sdk/src/index-dtf/dtf/exposure.test.ts b/packages/sdk/src/index-dtf/dtf/exposure.test.ts new file mode 100644 index 0000000..ff8cd3a --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/exposure.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DtfClient } from "@/client"; + +import { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; + +const DTF = "0x0000000000000000000000000000000000000001"; +const TOKEN = "0x00000000000000000000000000000000000000aa"; + +describe("getIndexDtfExposure", () => { + it("checksums token addresses and preserves the exposure/collateral market caps", async () => { + const get = vi.fn(async () => [ + { + native: { symbol: "AAPL", name: "Apple", logo: "a.svg", caip2: "eip155:1", coingeckoId: "apple", marketCap: 3_000_000 }, + totalWeight: 0.4, + marketCap: 500, + tokens: [ + { address: TOKEN, symbol: "uAAPL", weight: 0.4, marketCap: 500, underlyingMarketCap: 3_000_000, bridge: { id: "b", name: "Bridge", url: "u", description: "d", logo: "l", risks: [], wrappedVersion: true } }, + ], + }, + ]); + const client = { api: { get } } as unknown as DtfClient; + + const [group] = await getIndexDtfExposure(client, { address: DTF, chainId: 1, period: "ytd" }); + + expect(get).toHaveBeenCalledWith(expect.objectContaining({ query: expect.objectContaining({ period: "ytd" }) })); + expect(group!.native.marketCap).toBe(3_000_000); + const [token] = group!.tokens; + expect(token!.address).toBe("0x00000000000000000000000000000000000000AA"); + expect(token!.marketCap).toBe(500); + expect(token!.underlyingMarketCap).toBe(3_000_000); + expect(token!.bridge?.name).toBe("Bridge"); + }); + + it("defaults the period to 24h when unset", async () => { + const get = vi.fn(async () => []); + const client = { api: { get } } as unknown as DtfClient; + + await getIndexDtfExposure(client, { address: DTF, chainId: 1 }); + + expect(get).toHaveBeenCalledWith(expect.objectContaining({ query: expect.objectContaining({ period: "24h" }) })); + }); +}); diff --git a/packages/sdk/src/index-dtf/dtf/exposure.ts b/packages/sdk/src/index-dtf/dtf/exposure.ts index 6c7cc55..a28a94f 100644 --- a/packages/sdk/src/index-dtf/dtf/exposure.ts +++ b/packages/sdk/src/index-dtf/dtf/exposure.ts @@ -3,20 +3,49 @@ import { getAddress, type Address } from "viem"; import type { DtfClient } from "@/client"; import type { SupportedChainId } from "@/config"; -export type IndexDtfExposurePeriod = "24h" | "7d" | "1m" | "3m" | "1y" | "all"; +export type IndexDtfExposurePeriod = "24h" | "7d" | "1m" | "3m" | "1y" | "ytd" | "all"; + +/** The bridge protocol wrapping a tokenized asset, when the group is bridged. */ +export type IndexDtfExposureBridge = { + readonly id: string; + readonly name: string; + readonly url: string; + readonly description: string; + readonly logo: string; + readonly logoDark?: string | null; + readonly risks: readonly string[]; + readonly wrappedVersion: boolean; +}; + +/** The underlying real-world asset a group tracks (a tradfi equity, a native L1 coin). */ +export type IndexDtfExposureNative = { + readonly symbol: string; + readonly name: string; + readonly logo: string; + readonly caip2: string; + readonly address?: string; + readonly url?: string; + readonly coingeckoId?: string; + readonly marketCap?: number; + readonly price?: number; + readonly priceChange7d?: number; +}; export type IndexDtfExposureToken = { readonly address: Address; readonly symbol: string; readonly name?: string; readonly weight: number; + /** On-chain (tokenized supply) market cap — the Collateral-tab figure. */ readonly marketCap?: number; + /** Real-company market cap for tokenized equities — the Exposure-tab figure. */ + readonly underlyingMarketCap?: number; readonly change?: number; - readonly bridge?: unknown; + readonly bridge?: IndexDtfExposureBridge; }; export type IndexDtfExposureGroup = { - readonly native: unknown; + readonly native: IndexDtfExposureNative; readonly tokens: readonly IndexDtfExposureToken[]; readonly totalWeight: number; readonly change?: number; From 4a29ca58bcff2227325fd96991e66be47cef4706 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 14 Jul 2026 17:06:14 -0700 Subject: [PATCH 05/19] fix(index-dtf): price-history interval accepts 5m (the 24h chart granularity) The /historical/dtf endpoint serves 5m data for the 24h range, but the interval union only declared 1h|1d, forcing consumers to cast. Widened both the public param and the API-layer types; fixture pins the 5m passthrough. --- .../src/index-dtf/dtf/price-history.test.ts | 38 +++++++++++++++++++ packages/sdk/src/types/api.ts | 4 +- packages/sdk/src/types/index-dtf.ts | 2 +- 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/src/index-dtf/dtf/price-history.test.ts diff --git a/packages/sdk/src/index-dtf/dtf/price-history.test.ts b/packages/sdk/src/index-dtf/dtf/price-history.test.ts new file mode 100644 index 0000000..517d8b0 --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/price-history.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createDtfClient } from "@/client"; + +import { getPriceHistory } from "@/index-dtf/dtf"; + +const DTF = "0x0000000000000000000000000000000000000001"; + +describe("getPriceHistory", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requests /historical/dtf with a 5m interval (the 24h chart granularity) and maps the response", async () => { + const fetch = vi.fn(async () => + Response.json({ + timeseries: [{ timestamp: 100, price: 1.5, marketCap: 10, totalSupply: 5, basket: [] }], + }), + ); + vi.stubGlobal("fetch", fetch); + + const client = createDtfClient({ apiBaseUrl: "https://api.example" }); + + const points = await getPriceHistory(client, { + address: DTF, + chainId: 8453, + from: 0, + to: 100, + interval: "5m", + }); + + const [url] = fetch.mock.calls[0] as unknown as [URL]; + expect(String(url)).toBe( + "https://api.example/historical/dtf?chainId=8453&address=0x0000000000000000000000000000000000000001&from=0&to=100&interval=5m", + ); + expect(points).toEqual([{ timestamp: 100, price: 1.5, marketCap: 10, totalSupply: 5, basket: [] }]); + }); +}); diff --git a/packages/sdk/src/types/api.ts b/packages/sdk/src/types/api.ts index 042485b..223f7ca 100644 --- a/packages/sdk/src/types/api.ts +++ b/packages/sdk/src/types/api.ts @@ -125,7 +125,7 @@ export type GetHistoricalTokenPricesParams = { readonly address: Address; readonly from: number; readonly to: number; - readonly interval: "1h" | "1d"; + readonly interval: "5m" | "1h" | "1d"; }; export type GetIndexDtfPriceParams = { @@ -136,7 +136,7 @@ export type GetIndexDtfPriceParams = { export type GetIndexDtfPriceHistoryParams = GetIndexDtfPriceParams & { readonly from: number; readonly to: number; - readonly interval: "1h" | "1d"; + readonly interval: "5m" | "1h" | "1d"; }; export type GetIndexDtfBasketSnapshotParams = GetIndexDtfPriceParams & { diff --git a/packages/sdk/src/types/index-dtf.ts b/packages/sdk/src/types/index-dtf.ts index 1b5ccba..1830b60 100644 --- a/packages/sdk/src/types/index-dtf.ts +++ b/packages/sdk/src/types/index-dtf.ts @@ -279,7 +279,7 @@ export type GetIndexDtfPricesParams = { export type GetIndexDtfPriceHistoryParams = IndexDtfIdentityParams & { readonly from: number; readonly to: number; - readonly interval: "1h" | "1d"; + readonly interval: "5m" | "1h" | "1d"; }; export type GetIndexDtfPriceHistoryOptions = Pick; From 7480fb2a2c84454e17a151d6e0873143edd0eaa9 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 14 Jul 2026 20:46:14 -0700 Subject: [PATCH 06/19] fix(rebalance): completed-rebalance detail read works end-to-end (Z30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Reserve API /dtf/rebalance nonce query returns a single-element ARRAY, not an object (verified against api.reserve.org, base/lcap nonces 1-5). getCompletedRebalance read it as an object, so the mapper received the array, .auctions was undefined, and it threw INVALID_RESPONSE on every real response — the read was broken end-to-end, unnoticed because it had no consumer. Fixes: - Normalize the single-element-array envelope inside the client api method (getIndexDtfRebalanceDetail stays singular); throw RECORD_NOT_FOUND on empty. - Complete IndexDtfCompletedRebalanceDetail with the analytics fields the detail surface renders (avgPriceImpactPercent, totalPriceImpactUsd, marketCapRebalanceImpact, tracking/nativeBasketDeviation) so consumers can drop the raw /dtf/rebalance fetch. - Keep the mapper fail-loud on a truly-absent auctions field: auctions is always present ([] when none ran), so absence is malformed, not '0 auctions'. Tests: array-unwrap + not-found (api), singular map (index), normal/present-empty/ malformed (mappers). --- packages/sdk/src/client/api.test.ts | 18 +++- packages/sdk/src/client/api/index.ts | 18 +++- .../sdk/src/index-dtf/rebalance/index.test.ts | 30 ++++++ .../src/index-dtf/rebalance/mappers.test.ts | 97 +++++++++++++++++++ .../sdk/src/index-dtf/rebalance/mappers.ts | 8 ++ packages/sdk/src/index-dtf/rebalance/types.ts | 5 + packages/sdk/src/types/api.ts | 9 +- 7 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/src/index-dtf/rebalance/index.test.ts create mode 100644 packages/sdk/src/index-dtf/rebalance/mappers.test.ts diff --git a/packages/sdk/src/client/api.test.ts b/packages/sdk/src/client/api.test.ts index 614baea..ab5c0ac 100644 --- a/packages/sdk/src/client/api.test.ts +++ b/packages/sdk/src/client/api.test.ts @@ -111,7 +111,7 @@ describe("Reserve API helpers", () => { const value = String(url); if (value.includes("nonce=4")) { - return Response.json({ nonce: 4, timestamp: 100, auctions: [] }); + return Response.json([{ nonce: 4, timestamp: 100, auctions: [] }]); } return Response.json([ @@ -140,6 +140,8 @@ describe("Reserve API helpers", () => { }); expect(history[0]?.totalRebalancedUsd).toBe(50); + // The nonce endpoint returns a single-element array; the client normalizes + // it to the singular detail. expect(detail.nonce).toBe(4); expect(String((fetch.mock.calls[0] as unknown as [URL])[0])).toBe( "https://api.example/dtf/rebalance?address=0x0000000000000000000000000000000000000001&chainId=1&skip=10&limit=5", @@ -148,4 +150,18 @@ describe("Reserve API helpers", () => { "https://api.example/dtf/rebalance?address=0x0000000000000000000000000000000000000001&chainId=1&nonce=4", ); }); + + it("throws RECORD_NOT_FOUND when the rebalance detail array is empty", async () => { + const fetch = vi.fn(async () => Response.json([])); + vi.stubGlobal("fetch", fetch); + const client = createDtfClient({ apiBaseUrl: "https://api.example" }); + + await expect( + client.api.getIndexDtfRebalanceDetail({ + address: "0x0000000000000000000000000000000000000001", + chainId: 1, + nonce: 9n, + }), + ).rejects.toMatchObject({ code: "RECORD_NOT_FOUND" }); + }); }); diff --git a/packages/sdk/src/client/api/index.ts b/packages/sdk/src/client/api/index.ts index 3e4843a..a1ece68 100644 --- a/packages/sdk/src/client/api/index.ts +++ b/packages/sdk/src/client/api/index.ts @@ -12,6 +12,7 @@ import type { } from "@/types/api"; import { getApiTokenPrices, getBasketTokenPricesWithSnapshot } from "@/client/api/prices"; +import { SdkError } from "@/lib/errors"; import { uniqueAddresses } from "@/lib/utils"; import { get, post, type GetOptions } from "@/transports/api"; @@ -113,8 +114,10 @@ export function createDtfClientApi({ baseUrl }: { readonly baseUrl: string }): D }, }); }, - getIndexDtfRebalanceDetail(params) { - return apiGet({ + async getIndexDtfRebalanceDetail(params) { + // The nonce-filtered endpoint returns a single-element array; normalize the + // envelope here so callers get one detail record. + const response = await apiGet({ path: "/dtf/rebalance", query: { address: getAddress(params.address).toLowerCase(), @@ -122,6 +125,17 @@ export function createDtfClientApi({ baseUrl }: { readonly baseUrl: string }): D nonce: String(params.nonce), }, }); + + const detail = response[0]; + if (!detail) { + throw new SdkError({ + code: "RECORD_NOT_FOUND", + message: `Reserve API returned no rebalance detail for nonce ${String(params.nonce)}`, + meta: { chainId: params.chainId, nonce: String(params.nonce) }, + }); + } + + return detail; }, }; } diff --git a/packages/sdk/src/index-dtf/rebalance/index.test.ts b/packages/sdk/src/index-dtf/rebalance/index.test.ts new file mode 100644 index 0000000..8fed944 --- /dev/null +++ b/packages/sdk/src/index-dtf/rebalance/index.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DtfClient } from "@/client"; + +import { getCompletedRebalance } from "@/index-dtf/rebalance/index"; + +const DTF = "0x0000000000000000000000000000000000000001"; + +describe("getCompletedRebalance", () => { + it("maps the singular detail the client returns", async () => { + const getIndexDtfRebalanceDetail = vi.fn(async () => ({ + nonce: 4, + timestamp: 100, + totalRebalancedUsd: 50, + avgPriceImpactPercent: 0.3, + auctions: [], + })); + const client = { api: { getIndexDtfRebalanceDetail } } as unknown as DtfClient; + + const detail = await getCompletedRebalance(client, { address: DTF, chainId: 1, nonce: 4 }); + + expect(detail.nonce).toBe(4); + expect(detail.totalRebalancedUsd).toBe(50); + expect(detail.avgPriceImpactPercent).toBe(0.3); + expect(detail.auctions).toEqual([]); + expect(getIndexDtfRebalanceDetail).toHaveBeenCalledWith( + expect.objectContaining({ chainId: 1, nonce: 4 }), + ); + }); +}); diff --git a/packages/sdk/src/index-dtf/rebalance/mappers.test.ts b/packages/sdk/src/index-dtf/rebalance/mappers.test.ts new file mode 100644 index 0000000..658194b --- /dev/null +++ b/packages/sdk/src/index-dtf/rebalance/mappers.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import type { ReserveApiIndexDtfRebalanceDetail } from "@/types/api"; + +import { mapApiCompletedRebalanceDetail } from "@/index-dtf/rebalance/mappers"; + +const SELL_TOKEN = { + address: "0x0000000000000000000000000000000000000011", + symbol: "SELL", + decimals: 18, +}; +const BUY_TOKEN = { + address: "0x0000000000000000000000000000000000000022", + symbol: "BUY", + decimals: 6, +}; + +const withAuctions: ReserveApiIndexDtfRebalanceDetail = { + nonce: 7, + timestamp: 1_700_000_000, + totalRebalancedUsd: 1234.5, + rebalanceAccuracy: 98.7, + avgPriceImpactPercent: -0.42, + totalPriceImpactUsd: -5.5, + marketCapRebalanceImpact: 1.1, + trackingBasketDeviation: 2.2, + nativeBasketDeviation: 3.3, + auctions: [ + { + startTime: 1_700_000_100, + endTime: 1_700_000_200, + totalSellAmountUsd: 600, + totalBuyAmountUsd: 610, + bids: [ + { + bidder: "0x0000000000000000000000000000000000000099", + sellToken: SELL_TOKEN, + buyToken: BUY_TOKEN, + sellAmount: "1000000000000000000", + buyAmount: "990000", + sellAmountUsd: 600, + buyAmountUsd: 610, + priceImpactUsd: -2.5, + }, + ], + }, + ], +}; + +// A completed rebalance that ran no auctions returns `auctions: []` present-empty +// (verified against api.reserve.org base/lcap nonce 5), with some analytics +// fields absent. This maps to an empty list with no special handling. +const emptyAuctions: ReserveApiIndexDtfRebalanceDetail = { + nonce: 8, + timestamp: 1_700_100_000, + totalRebalancedUsd: 0, + rebalanceAccuracy: 100, + auctions: [], +}; + +// A truly-absent `auctions` field never occurs in a well-formed response, so it +// is treated as malformed — fail loud rather than mask it as "0 auctions". +const malformed = { + nonce: 9, + timestamp: 1_700_200_000, +} as ReserveApiIndexDtfRebalanceDetail; + +describe("mapApiCompletedRebalanceDetail", () => { + it("maps a normal completed rebalance with auctions and analytics", () => { + const result = mapApiCompletedRebalanceDetail(withAuctions); + + expect(result.auctions).toHaveLength(1); + expect(result.auctions[0]?.bids[0]?.bidder).toBe("0x0000000000000000000000000000000000000099"); + expect(result.auctions[0]?.bids[0]?.sellAmount).toBe(1000000000000000000n); + expect(result.avgPriceImpactPercent).toBe(-0.42); + expect(result.totalPriceImpactUsd).toBe(-5.5); + expect(result.marketCapRebalanceImpact).toBe(1.1); + expect(result.trackingBasketDeviation).toBe(2.2); + expect(result.nativeBasketDeviation).toBe(3.3); + expect(result.rebalanceAccuracy).toBe(98.7); + }); + + it("maps a present-empty auctions list to an empty list", () => { + const result = mapApiCompletedRebalanceDetail(emptyAuctions); + + expect(result.auctions).toEqual([]); + expect(result.nonce).toBe(8); + expect(result.rebalanceAccuracy).toBe(100); + // Absent analytics fields stay absent — never fabricated. + expect(result.avgPriceImpactPercent).toBeUndefined(); + expect(result.totalPriceImpactUsd).toBeUndefined(); + }); + + it("throws INVALID_RESPONSE when auctions is truly absent (malformed)", () => { + expect(() => mapApiCompletedRebalanceDetail(malformed)).toThrow(/missing auctions/i); + }); +}); diff --git a/packages/sdk/src/index-dtf/rebalance/mappers.ts b/packages/sdk/src/index-dtf/rebalance/mappers.ts index ec86c72..49ae80d 100644 --- a/packages/sdk/src/index-dtf/rebalance/mappers.ts +++ b/packages/sdk/src/index-dtf/rebalance/mappers.ts @@ -95,6 +95,9 @@ export function mapApiCompletedRebalance(rebalance: ApiCompletedRebalance): Inde export function mapApiCompletedRebalanceDetail( rebalance: ReserveApiIndexDtfRebalanceDetail, ): IndexDtfCompletedRebalanceDetail { + // WHY: `auctions` is always present in a well-formed response ([] when none + // ran — verified against api.reserve.org base/lcap nonces 1-5). A truly-absent + // field is malformed, so fail loud rather than mask it as "0 auctions". if (!rebalance.auctions) { throw new SdkError({ code: "INVALID_RESPONSE", @@ -123,6 +126,11 @@ export function mapApiCompletedRebalanceDetail( })), ...numberField("rebalanceGainLossPercent", rebalance.rebalanceGainLossPercent), ...numberField("marketCapAtStart", rebalance.marketCapAtStart), + ...numberField("avgPriceImpactPercent", rebalance.avgPriceImpactPercent), + ...numberField("totalPriceImpactUsd", rebalance.totalPriceImpactUsd), + ...numberField("marketCapRebalanceImpact", rebalance.marketCapRebalanceImpact), + ...numberField("trackingBasketDeviation", rebalance.trackingBasketDeviation), + ...numberField("nativeBasketDeviation", rebalance.nativeBasketDeviation), }; } diff --git a/packages/sdk/src/index-dtf/rebalance/types.ts b/packages/sdk/src/index-dtf/rebalance/types.ts index 164cfa8..393c593 100644 --- a/packages/sdk/src/index-dtf/rebalance/types.ts +++ b/packages/sdk/src/index-dtf/rebalance/types.ts @@ -116,6 +116,11 @@ export type IndexDtfCompletedRebalanceDetail = IndexDtfCompletedRebalance & { readonly auctions: readonly IndexDtfCompletedRebalanceAuction[]; readonly rebalanceGainLossPercent?: number; readonly marketCapAtStart?: number; + readonly avgPriceImpactPercent?: number; + readonly totalPriceImpactUsd?: number; + readonly marketCapRebalanceImpact?: number; + readonly trackingBasketDeviation?: number; + readonly nativeBasketDeviation?: number; }; export type IndexDtfCurrentRebalance = Rebalance & { diff --git a/packages/sdk/src/types/api.ts b/packages/sdk/src/types/api.ts index 223f7ca..7a99c67 100644 --- a/packages/sdk/src/types/api.ts +++ b/packages/sdk/src/types/api.ts @@ -70,7 +70,9 @@ export type ReserveApiIndexDtfRebalanceHistoryItem = { export type ReserveApiIndexDtfRebalanceDetail = { readonly nonce: number; readonly timestamp: number; - readonly auctions?: readonly { + // Always present in a well-formed response ([] when no auctions ran); a + // truly-absent field is malformed and the mapper fails loud on it. + readonly auctions: readonly { readonly startTime: number; readonly endTime: number; readonly bids: readonly { @@ -102,6 +104,11 @@ export type ReserveApiIndexDtfRebalanceDetail = { readonly rebalanceGainLossPercent?: number | null; readonly marketCapAtStart?: number | null; readonly rebalanceAccuracy?: number | null; + readonly avgPriceImpactPercent?: number | null; + readonly totalPriceImpactUsd?: number | null; + readonly marketCapRebalanceImpact?: number | null; + readonly trackingBasketDeviation?: number | null; + readonly nativeBasketDeviation?: number | null; readonly isNative?: boolean | null; }; From 536e8099183e424de595ec4876761246e9e0105e Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 20 Jul 2026 10:33:07 -0700 Subject: [PATCH 07/19] fix(governance): index-dtf stale-PENDING past deadline derives the vote outcome, never EXPIRED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FolioGovernor.state() never returns Expired for an unqueued proposal — past the deadline it derives Defeated/Succeeded from the votes. A subgraph state stuck PENDING past voteEnd now resolves like a stale ACTIVE (tie/zero-for -> DEFEATED, under quorum -> QUORUM_NOT_REACHED, else SUCCEEDED), mirroring the yield-side getYieldDtfProposalState semantics. Optimistic paths and the exact-boundary (<=) behavior are unchanged; the shared outcome logic is extracted as getStandardFinalState next to getOptimisticFinalState. --- .../src/index-dtf/governance/utils.test.ts | 59 +++++++++++++++++++ .../sdk/src/index-dtf/governance/utils.ts | 33 +++++++---- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/packages/sdk/src/index-dtf/governance/utils.test.ts b/packages/sdk/src/index-dtf/governance/utils.test.ts index 57f120e..48b434e 100644 --- a/packages/sdk/src/index-dtf/governance/utils.test.ts +++ b/packages/sdk/src/index-dtf/governance/utils.test.ts @@ -69,3 +69,62 @@ describe("getProposalState standard vote outcomes", () => { ).toMatchObject({ state: "QUORUM_NOT_REACHED" }); }); }); + +const STALE_PENDING_PROPOSAL = { + ...ENDED_STANDARD_PROPOSAL, + state: "PENDING", +} as const; + +describe("getProposalState stale PENDING past the deadline", () => { + it("keeps a stale pending proposal active at the vote end boundary", () => { + expect( + getProposalState( + { + ...STALE_PENDING_PROPOSAL, + forWeightedVotes: { raw: 150n, formatted: "150" }, + againstWeightedVotes: { raw: 150n, formatted: "150" }, + }, + 200, + ), + ).toMatchObject({ state: "ACTIVE" }); + }); + + it("succeeds after the deadline when for votes win and quorum is met", () => { + expect( + getProposalState( + { + ...STALE_PENDING_PROPOSAL, + forWeightedVotes: { raw: 150n, formatted: "150" }, + againstWeightedVotes: { raw: 10n, formatted: "10" }, + }, + 201, + ), + ).toMatchObject({ state: "SUCCEEDED" }); + }); + + it("defeats a tie after the deadline", () => { + expect( + getProposalState( + { + ...STALE_PENDING_PROPOSAL, + forWeightedVotes: { raw: 150n, formatted: "150" }, + againstWeightedVotes: { raw: 150n, formatted: "150" }, + }, + 201, + ), + ).toMatchObject({ state: "DEFEATED" }); + }); + + it("marks winning votes under quorum as quorum not reached after the deadline", () => { + expect( + getProposalState( + { + ...STALE_PENDING_PROPOSAL, + forWeightedVotes: { raw: 50n, formatted: "50" }, + againstWeightedVotes: { raw: 10n, formatted: "10" }, + }, + 201, + ), + ).toMatchObject({ state: "QUORUM_NOT_REACHED" }); + }); +}); diff --git a/packages/sdk/src/index-dtf/governance/utils.ts b/packages/sdk/src/index-dtf/governance/utils.ts index c8f775e..5a1e10a 100644 --- a/packages/sdk/src/index-dtf/governance/utils.ts +++ b/packages/sdk/src/index-dtf/governance/utils.ts @@ -131,7 +131,10 @@ export function getProposalState(proposal: ProposalVoteStateInput, timestamp = g state.state = "ACTIVE"; state.deadline = proposal.voteEnd - timestamp; } else { - state.state = "EXPIRED"; + // WHY: past the deadline FolioGovernor.state() derives Defeated/Succeeded + // from the vote counts even if the ACTIVE transition was never indexed — + // a stale PENDING is never EXPIRED (that is a queue-lifecycle state). + state.state = getStandardFinalState(proposal); } } else if (proposal.state === "ACTIVE") { if (optimisticTransitioned) { @@ -141,17 +144,9 @@ export function getProposalState(proposal: ProposalVoteStateInput, timestamp = g } else if (optimisticVetoReached) { state.state = "DEFEATED"; } else if (timestamp > proposal.voteEnd) { - if (isOptimistic) { - state.state = getOptimisticFinalState(proposal, optimisticVetoThresholdVotes); - } else if (proposal.forWeightedVotes.raw <= proposal.againstWeightedVotes.raw) { - // WHY: OZ GovernorCountingSimple._voteSucceeded requires forVotes STRICTLY - // over againstVotes (FolioGovernor does not override it) — a tie is defeated. - state.state = "DEFEATED"; - } else if (proposal.forWeightedVotes.raw + proposal.abstainWeightedVotes.raw < quorumVotes) { - state.state = "QUORUM_NOT_REACHED"; - } else { - state.state = "SUCCEEDED"; - } + state.state = isOptimistic + ? getOptimisticFinalState(proposal, optimisticVetoThresholdVotes) + : getStandardFinalState(proposal); } else { state.deadline = proposal.voteEnd - timestamp; } @@ -267,6 +262,20 @@ export function getOptimisticVetoThresholdVotes( return vetoThresholdVotes === 0n ? 1n : vetoThresholdVotes; } +function getStandardFinalState(proposal: ProposalVoteStateInput): ProposalState { + // WHY: OZ GovernorCountingSimple._voteSucceeded requires forVotes STRICTLY + // over againstVotes (FolioGovernor does not override it) — a tie is defeated. + if (proposal.forWeightedVotes.raw <= proposal.againstWeightedVotes.raw) { + return "DEFEATED"; + } + + if (proposal.forWeightedVotes.raw + proposal.abstainWeightedVotes.raw < proposal.quorumVotes.raw) { + return "QUORUM_NOT_REACHED"; + } + + return "SUCCEEDED"; +} + function getOptimisticFinalState( proposal: ProposalVoteStateInput, vetoThresholdVotes: bigint | undefined, From c51c12044a058013316960754ad730a3b6f9ebda Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 20 Jul 2026 10:33:12 -0700 Subject: [PATCH 08/19] chore: per-change changesets; minor bump for the 0.4.x breaking surface The release changes behavior and types 0.4.x consumers depend on: YieldDtfProposalState gained QUORUM_NOT_REACHED, rebalance detail auctions went optional->required, and getIndexDtfPlatformFee throws where it returned 0%. Those (plus the exposure typing and 5m interval additions) get minor changesets; the stale-PENDING outcome derivation stays a patch note. --- .changeset/exposure-api-types.md | 5 +++++ .changeset/platform-fee-invalid-response.md | 5 +++++ .changeset/price-history-5m-interval.md | 5 +++++ .changeset/rebalance-detail-contract.md | 5 +++++ .changeset/stale-pending-outcomes.md | 5 +++++ .changeset/strict-tie-semantics.md | 4 ++-- 6 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 .changeset/exposure-api-types.md create mode 100644 .changeset/platform-fee-invalid-response.md create mode 100644 .changeset/price-history-5m-interval.md create mode 100644 .changeset/rebalance-detail-contract.md create mode 100644 .changeset/stale-pending-outcomes.md diff --git a/.changeset/exposure-api-types.md b/.changeset/exposure-api-types.md new file mode 100644 index 0000000..4bd1f4e --- /dev/null +++ b/.changeset/exposure-api-types.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": minor +--- + +Typed the Index DTF exposure API contract to match the live response: `period` accepts `ytd`, `underlyingMarketCap` is exposed, and native/bridge metadata is fully typed instead of `unknown` — no more consumer-side casts. diff --git a/.changeset/platform-fee-invalid-response.md b/.changeset/platform-fee-invalid-response.md new file mode 100644 index 0000000..eab7f5d --- /dev/null +++ b/.changeset/platform-fee-invalid-response.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": minor +--- + +Breaking: `getIndexDtfPlatformFee` now throws `SdkError` (`INVALID_RESPONSE`) when the DAO fee registry returns a zero denominator, instead of fabricating a confident 0% fee. A real 0% (zero numerator over a positive denominator) is unaffected; consumers should treat the error as "fee unavailable". diff --git a/.changeset/price-history-5m-interval.md b/.changeset/price-history-5m-interval.md new file mode 100644 index 0000000..859b700 --- /dev/null +++ b/.changeset/price-history-5m-interval.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": minor +--- + +`getIndexDtfPriceHistory` interval accepts `"5m"`, the granularity the API serves for the 24h range — previously the union only declared `1h | 1d` and forced a cast. diff --git a/.changeset/rebalance-detail-contract.md b/.changeset/rebalance-detail-contract.md new file mode 100644 index 0000000..cd0ae7b --- /dev/null +++ b/.changeset/rebalance-detail-contract.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": minor +--- + +Breaking: `ReserveApiIndexDtfRebalanceDetail.auctions` is now required (`[]` when no auctions ran); a truly-absent field is treated as a malformed response and fails loud. Completed-rebalance detail reads now work end-to-end: the single-element-array envelope from `/dtf/rebalance` is normalized inside the client (`RECORD_NOT_FOUND` on empty), and `IndexDtfCompletedRebalanceDetail` carries the analytics fields the detail surface renders (avgPriceImpactPercent, totalPriceImpactUsd, marketCapRebalanceImpact, tracking/native basket deviation). diff --git a/.changeset/stale-pending-outcomes.md b/.changeset/stale-pending-outcomes.md new file mode 100644 index 0000000..978de9b --- /dev/null +++ b/.changeset/stale-pending-outcomes.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": patch +--- + +A proposal stuck in subgraph PENDING past its vote deadline now derives the vote outcome (DEFEATED / QUORUM_NOT_REACHED / SUCCEEDED) like a stale ACTIVE, matching Governor.state() semantics — it is never reported as EXPIRED. Applies to both Index DTF (`getProposalState`) and Yield DTF (`getYieldDtfProposalState`). Yield proposal reads also derive the current timepoint from the chain (block timestamp or number per governor version) instead of the consumer machine's clock. diff --git a/.changeset/strict-tie-semantics.md b/.changeset/strict-tie-semantics.md index ea107e6..4fd119d 100644 --- a/.changeset/strict-tie-semantics.md +++ b/.changeset/strict-tie-semantics.md @@ -1,5 +1,5 @@ --- -"@reserve-protocol/sdk": patch +"@reserve-protocol/sdk": minor --- -Match OZ Governor strict-majority semantics in Index DTF proposal state derivation (a for/against tie is now DEFEATED, not SUCCEEDED) and derive lagging Yield DTF proposal list states (ACTIVE past the vote deadline now resolves to DEFEATED/QUORUM_NOT_REACHED/SUCCEEDED) via the new getYieldDtfProposalState. +Match OZ Governor strict-majority semantics in Index DTF proposal state derivation (a for/against tie is now DEFEATED, not SUCCEEDED) and derive lagging Yield DTF proposal list states (ACTIVE past the vote deadline now resolves to DEFEATED/QUORUM_NOT_REACHED/SUCCEEDED) via the new getYieldDtfProposalState. Breaking: the `YieldDtfProposalState` union gained `"QUORUM_NOT_REACHED"` — exhaustive switches over it must handle the new member. From 1df9528705efedb30f0347e475724565770514f7 Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 20 Jul 2026 11:17:05 -0700 Subject: [PATCH 09/19] feat(index-dtf): brand maps to the display-ready single shape IndexDtfBrand is now the exact shape consumers store: nested dtf block, creator/curator/socials always present, absent API values coerced to '' defaults, basketType narrowed to percentage-based | unit-based. Removes the need for app-side brand mapping layers. --- .changeset/brand-display-shape.md | 5 ++ packages/sdk/src/index-dtf/dtf/index.test.ts | 76 +++++++++++++++--- packages/sdk/src/index-dtf/dtf/mappers.ts | 81 +++++++------------- packages/sdk/src/types/index-dtf.ts | 44 ++++++----- 4 files changed, 121 insertions(+), 85 deletions(-) create mode 100644 .changeset/brand-display-shape.md diff --git a/.changeset/brand-display-shape.md b/.changeset/brand-display-shape.md new file mode 100644 index 0000000..8f5c695 --- /dev/null +++ b/.changeset/brand-display-shape.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/sdk": minor +--- + +Breaking: `IndexDtfBrand` is now the display-ready shape consumers can store directly — `{ hidden, dtf: { icon, cover, mobileCover, video, description, notesFromCreator, prospectus, files, tags, basketType }, creator, curator, socials }`. Every field is always present: absent API values coerce to `""`, `files` is always `{ url, name }[]`, `tags` always `string[]`, and `basketType` narrows to `"percentage-based" | "unit-based"` (defaulting `"percentage-based"`). App-side brand mapping layers are no longer needed. diff --git a/packages/sdk/src/index-dtf/dtf/index.test.ts b/packages/sdk/src/index-dtf/dtf/index.test.ts index 924e735..f14dca4 100644 --- a/packages/sdk/src/index-dtf/dtf/index.test.ts +++ b/packages/sdk/src/index-dtf/dtf/index.test.ts @@ -288,29 +288,36 @@ describe("Index DTF getters", () => { chainId: 8453, }); - expect(brand).toMatchObject({ + expect(brand).toEqual({ hidden: false, - icon: "https://example.com/icon.png", - description: "CMC20 brand", - tags: ["Majors", "DeFi"], - basketType: "percentage-based", - video: "https://www.youtube.com/watch?v=brand", - files: [ - { url: "https://example.com/factsheet.pdf", name: "Factsheet" }, - { url: "", name: "" }, - ], + dtf: { + icon: "https://example.com/icon.png", + cover: "", + mobileCover: "", + video: "https://www.youtube.com/watch?v=brand", + description: "CMC20 brand", + notesFromCreator: "", + prospectus: "", + files: [ + { url: "https://example.com/factsheet.pdf", name: "Factsheet" }, + { url: "", name: "" }, + ], + tags: ["Majors", "DeFi"], + basketType: "percentage-based", + }, creator: { name: "ListaDAO", icon: "https://example.com/creator.png", link: "https://lista.org/", }, + curator: { name: "", icon: "", link: "" }, socials: { twitter: "https://x.com/CoinMarketCap", + telegram: "", + discord: "", website: "https://coinmarketcap.com/charts/cmc20/", }, }); - expect(brand?.cover).toBeUndefined(); - expect(brand?.curator).toBeUndefined(); expect(fetch).toHaveBeenCalledOnce(); const [brandUrl] = fetch.mock.calls[0] as unknown as [URL]; @@ -319,6 +326,51 @@ describe("Index DTF getters", () => { ); }); + it("maps a fully-null API brand to the all-defaults shape", async () => { + const fetch = vi.fn(async () => + Response.json({ + status: "ok", + parsedData: { + hidden: false, + dtf: { + icon: null, + cover: null, + description: null, + files: null, + tags: null, + basketType: null, + }, + }, + }), + ); + vi.stubGlobal("fetch", fetch); + + const apiClient = createDtfClient({ apiBaseUrl: "https://api.example" }); + const brand = await getBrand(apiClient, { + address: "0x0000000000000000000000000000000000000001", + chainId: 8453, + }); + + expect(brand).toEqual({ + hidden: false, + dtf: { + icon: "", + cover: "", + mobileCover: "", + video: "", + description: "", + notesFromCreator: "", + prospectus: "", + files: [], + tags: [], + basketType: "percentage-based", + }, + creator: { name: "", icon: "", link: "" }, + curator: { name: "", icon: "", link: "" }, + socials: { twitter: "", telegram: "", discord: "", website: "" }, + }); + }); + it("fetches and maps API-backed Index DTF price history", async () => { const fetch = vi.fn(async () => Response.json({ diff --git a/packages/sdk/src/index-dtf/dtf/mappers.ts b/packages/sdk/src/index-dtf/dtf/mappers.ts index 60c25ab..345e8e3 100644 --- a/packages/sdk/src/index-dtf/dtf/mappers.ts +++ b/packages/sdk/src/index-dtf/dtf/mappers.ts @@ -181,35 +181,27 @@ export function mapIndexDtfBrand(response: IndexDtfBrandResponse): IndexDtfBrand } const { curator, creator, dtf, hidden, socials } = response.parsedData; - const mappedCreator = mapBrandProfile(creator); - const mappedCurator = mapBrandProfile(curator); - const icon = nonEmpty(dtf?.icon); - const cover = nonEmpty(dtf?.cover); - const mobileCover = nonEmpty(dtf?.mobileCover); - const description = nonEmpty(dtf?.description); - const notesFromCreator = nonEmpty(dtf?.notesFromCreator); - const prospectus = nonEmpty(dtf?.prospectus); - const video = nonEmpty(dtf?.video); - const basketType = nonEmpty(dtf?.basketType); return { hidden, - tags: dtf?.tags ?? [], - files: (dtf?.files ?? []).map((file) => ({ - url: file.url ?? "", - name: file.name ?? "", - })), + dtf: { + icon: nonEmpty(dtf?.icon) ?? "", + cover: nonEmpty(dtf?.cover) ?? "", + mobileCover: nonEmpty(dtf?.mobileCover) ?? "", + video: nonEmpty(dtf?.video) ?? "", + description: nonEmpty(dtf?.description) ?? "", + notesFromCreator: nonEmpty(dtf?.notesFromCreator) ?? "", + prospectus: nonEmpty(dtf?.prospectus) ?? "", + files: (dtf?.files ?? []).map((file) => ({ + url: file.url ?? "", + name: file.name ?? "", + })), + tags: dtf?.tags ?? [], + basketType: dtf?.basketType === "unit-based" ? "unit-based" : "percentage-based", + }, + creator: mapBrandProfile(creator), + curator: mapBrandProfile(curator), socials: mapBrandSocials(socials), - ...(icon ? { icon } : {}), - ...(cover ? { cover } : {}), - ...(mobileCover ? { mobileCover } : {}), - ...(description ? { description } : {}), - ...(notesFromCreator ? { notesFromCreator } : {}), - ...(prospectus ? { prospectus } : {}), - ...(video ? { video } : {}), - ...(basketType ? { basketType } : {}), - ...(mappedCreator ? { creator: mappedCreator } : {}), - ...(mappedCurator ? { curator: mappedCurator } : {}), }; } @@ -385,22 +377,12 @@ function mapTokenSnapshot(token: { }; } -function mapBrandProfile(profile: IndexDtfBrandResponseProfile | undefined): IndexDtfBrandProfile | undefined { - if (!profile) { - return undefined; - } - - const name = nonEmpty(profile.name); - const icon = nonEmpty(profile.icon); - const link = nonEmpty(profile.link); - - return name || icon || link - ? { - ...(name ? { name } : {}), - ...(icon ? { icon } : {}), - ...(link ? { link } : {}), - } - : undefined; +function mapBrandProfile(profile: IndexDtfBrandResponseProfile | undefined): IndexDtfBrandProfile { + return { + name: nonEmpty(profile?.name) ?? "", + icon: nonEmpty(profile?.icon) ?? "", + link: nonEmpty(profile?.link) ?? "", + }; } function mapBrandSocials(socials?: { @@ -409,20 +391,11 @@ function mapBrandSocials(socials?: { readonly discord?: string | null; readonly website?: string | null; }): IndexDtfBrandSocials { - if (!socials) { - return {}; - } - - const twitter = nonEmpty(socials.twitter); - const telegram = nonEmpty(socials.telegram); - const discord = nonEmpty(socials.discord); - const website = nonEmpty(socials.website); - return { - ...(twitter ? { twitter } : {}), - ...(telegram ? { telegram } : {}), - ...(discord ? { discord } : {}), - ...(website ? { website } : {}), + twitter: nonEmpty(socials?.twitter) ?? "", + telegram: nonEmpty(socials?.telegram) ?? "", + discord: nonEmpty(socials?.discord) ?? "", + website: nonEmpty(socials?.website) ?? "", }; } diff --git a/packages/sdk/src/types/index-dtf.ts b/packages/sdk/src/types/index-dtf.ts index 1830b60..aac57fa 100644 --- a/packages/sdk/src/types/index-dtf.ts +++ b/packages/sdk/src/types/index-dtf.ts @@ -174,16 +174,16 @@ export type IndexDtfPricePoint = { }; export type IndexDtfBrandProfile = { - readonly name?: string; - readonly icon?: string; - readonly link?: string; + readonly name: string; + readonly icon: string; + readonly link: string; }; export type IndexDtfBrandSocials = { - readonly twitter?: string; - readonly telegram?: string; - readonly discord?: string; - readonly website?: string; + readonly twitter: string; + readonly telegram: string; + readonly discord: string; + readonly website: string; }; export type IndexDtfBrandFile = { @@ -191,20 +191,26 @@ export type IndexDtfBrandFile = { readonly name: string; }; +export type IndexDtfBasketType = "percentage-based" | "unit-based"; + +// Display-ready shape: every field present, absent API values coerced to "" +// defaults so consumers store it as-is without a mapping layer. export type IndexDtfBrand = { readonly hidden: boolean; - readonly icon?: string; - readonly cover?: string; - readonly mobileCover?: string; - readonly description?: string; - readonly notesFromCreator?: string; - readonly prospectus?: string; - readonly video?: string; - readonly files: readonly IndexDtfBrandFile[]; - readonly tags: readonly string[]; - readonly basketType?: string; - readonly creator?: IndexDtfBrandProfile; - readonly curator?: IndexDtfBrandProfile; + readonly dtf: { + readonly icon: string; + readonly cover: string; + readonly mobileCover: string; + readonly video: string; + readonly description: string; + readonly notesFromCreator: string; + readonly prospectus: string; + readonly files: readonly IndexDtfBrandFile[]; + readonly tags: readonly string[]; + readonly basketType: IndexDtfBasketType; + }; + readonly creator: IndexDtfBrandProfile; + readonly curator: IndexDtfBrandProfile; readonly socials: IndexDtfBrandSocials; }; From 0453499d8a18a738ac8a0f41e300078a2f3fbcff Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 20 Jul 2026 11:19:49 -0700 Subject: [PATCH 10/19] feat(index-dtf): status is a sync catalog lookup getIndexDtfStatus({ address, chainId }) reads dtf-catalog directly with case-insensitive address matching; absent entries are active. getFull and ref.getStatus share the same lookup. useIndexDtfStatus becomes a trivial sync wrapper; the async /discover scan, indexDtfStatusQueryOptions and the status query key are gone. --- .changeset/status-sync-catalog.md | 6 ++++ .../react-sdk/src/index-dtf-extra-hooks.ts | 9 ++--- .../react-sdk/src/index-dtf-query-options.ts | 13 ------- packages/react-sdk/src/index.ts | 1 - packages/react-sdk/src/query-keys.ts | 2 -- .../src/use-index-dtf-status.test.tsx | 34 ++++++++++++++++++ .../sdk/src/index-dtf/dtf/discovery.test.ts | 36 ------------------- packages/sdk/src/index-dtf/dtf/discovery.ts | 23 ------------ packages/sdk/src/index-dtf/dtf/index.ts | 4 +-- packages/sdk/src/index-dtf/dtf/status.test.ts | 26 ++++++++++++++ packages/sdk/src/index-dtf/dtf/status.ts | 23 ++++++++++++ packages/sdk/src/index-dtf/index.ts | 3 +- packages/sdk/src/index-dtf/namespace.ts | 4 +-- packages/sdk/src/index-dtf/ref.ts | 4 +-- 14 files changed, 100 insertions(+), 88 deletions(-) create mode 100644 .changeset/status-sync-catalog.md create mode 100644 packages/react-sdk/src/use-index-dtf-status.test.tsx create mode 100644 packages/sdk/src/index-dtf/dtf/status.test.ts create mode 100644 packages/sdk/src/index-dtf/dtf/status.ts diff --git a/.changeset/status-sync-catalog.md b/.changeset/status-sync-catalog.md new file mode 100644 index 0000000..9ed25c5 --- /dev/null +++ b/.changeset/status-sync-catalog.md @@ -0,0 +1,6 @@ +--- +"@reserve-protocol/sdk": minor +"@reserve-protocol/react-sdk": minor +--- + +Breaking: `getIndexDtfStatus` is now a synchronous catalog lookup — `getIndexDtfStatus({ address, chainId }): DtfStatus` reads `@reserve-protocol/dtf-catalog` directly (case-insensitive address match, absent entries are `active`) instead of scanning the `/discover/dtfs` endpoint. `useIndexDtfStatus({ address, chainId })` returns the status directly with no react-query involved; `indexDtfStatusQueryOptions` and the `dtfQueryKeys.index.status` key are removed. diff --git a/packages/react-sdk/src/index-dtf-extra-hooks.ts b/packages/react-sdk/src/index-dtf-extra-hooks.ts index 47b8ea2..509d432 100644 --- a/packages/react-sdk/src/index-dtf-extra-hooks.ts +++ b/packages/react-sdk/src/index-dtf-extra-hooks.ts @@ -27,7 +27,6 @@ import { indexDtfRebalanceQueryOptions, indexDtfRebalancesQueryOptions, indexDtfRevenueQueryOptions, - indexDtfStatusQueryOptions, indexDtfTransactionsQueryOptions, indexDtfTotalAssetsQueryOptions, indexDtfTotalSupplyQueryOptions, @@ -60,12 +59,10 @@ export function useIndexDtfTotalAssets>>( - params: MethodParams> | undefined, - options?: DtfQueryOptions>, TData>, -) { +/** Sync catalog lookup — no fetch; DTFs absent from the catalog are active. */ +export function useIndexDtfStatus(params: MethodParams> | undefined) { const sdk = useDtfSdk(); - return useQuery(indexDtfStatusQueryOptions(sdk, params, options)); + return params ? sdk.index.getStatus(params) : "active"; } export function useIndexDtfExposure>>( diff --git a/packages/react-sdk/src/index-dtf-query-options.ts b/packages/react-sdk/src/index-dtf-query-options.ts index 586cb0b..6ba6be3 100644 --- a/packages/react-sdk/src/index-dtf-query-options.ts +++ b/packages/react-sdk/src/index-dtf-query-options.ts @@ -53,19 +53,6 @@ export function indexDtfTotalAssetsQueryOptions>>( - sdk: DtfSdk, - params: MethodParams> | undefined, - options?: DtfQueryOptions>, TData>, -) { - return createDtfQueryOptions( - dtfQueryKeys.index.status(params), - () => sdk.index.getStatus(requireParams(params, "indexDtfStatusQueryOptions")), - params !== undefined, - options, - ); -} - export function indexDtfExposureQueryOptions>>( sdk: DtfSdk, params: MethodParams> | undefined, diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index a4b2864..18ed600 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -112,7 +112,6 @@ export { indexDtfRebalanceQueryOptions, indexDtfRebalancesQueryOptions, indexDtfRevenueQueryOptions, - indexDtfStatusQueryOptions, indexDtfTransactionsQueryOptions, indexDtfTotalAssetsQueryOptions, indexDtfTotalSupplyQueryOptions, diff --git a/packages/react-sdk/src/query-keys.ts b/packages/react-sdk/src/query-keys.ts index 86903f4..6a7b865 100644 --- a/packages/react-sdk/src/query-keys.ts +++ b/packages/react-sdk/src/query-keys.ts @@ -193,8 +193,6 @@ export const dtfQueryKeys = { price: (params?: GetIndexDtfPriceParams) => [...dtfQueryKeys.index.all(), "price", keyParams(params)] as const, priceHistory: (params?: GetIndexDtfPriceHistoryParams) => [...dtfQueryKeys.index.all(), "price-history", keyParams(params)] as const, - status: (params?: IndexMethodParams<"getStatus">) => - [...dtfQueryKeys.index.all(), "status", keyParams(params)] as const, exposure: (params?: IndexMethodParams<"getExposure">) => [...dtfQueryKeys.index.all(), "exposure", keyParams(params)] as const, holders: (params?: IndexMethodParams<"getHolders">) => diff --git a/packages/react-sdk/src/use-index-dtf-status.test.tsx b/packages/react-sdk/src/use-index-dtf-status.test.tsx new file mode 100644 index 0000000..06bb3a0 --- /dev/null +++ b/packages/react-sdk/src/use-index-dtf-status.test.tsx @@ -0,0 +1,34 @@ +import { createElement, type PropsWithChildren } from "react"; + +// @vitest-environment jsdom +import type { DtfSdk } from "@reserve-protocol/sdk"; + +import { getIndexDtfStatus } from "@reserve-protocol/sdk"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useIndexDtfStatus } from "@/index-dtf-extra-hooks"; +import { DtfSdkProvider } from "@/provider"; + +// Real catalog entry: VTF on Base, deprecated. +const DEPRECATED_BASE = "0x47686106181b3cefe4eaf94c4c10b48ac750370b"; + +const sdk = { index: { getStatus: getIndexDtfStatus } } as unknown as DtfSdk; + +function wrapper({ children }: PropsWithChildren) { + return createElement(DtfSdkProvider, { sdk }, children); +} + +describe("useIndexDtfStatus", () => { + it("resolves catalog status synchronously", () => { + const { result } = renderHook(() => useIndexDtfStatus({ address: DEPRECATED_BASE, chainId: 8453 }), { wrapper }); + + expect(result.current).toBe("deprecated"); + }); + + it("defaults to active without params", () => { + const { result } = renderHook(() => useIndexDtfStatus(undefined), { wrapper }); + + expect(result.current).toBe("active"); + }); +}); diff --git a/packages/sdk/src/index-dtf/dtf/discovery.test.ts b/packages/sdk/src/index-dtf/dtf/discovery.test.ts index 86b52ac..8fa51f1 100644 --- a/packages/sdk/src/index-dtf/dtf/discovery.test.ts +++ b/packages/sdk/src/index-dtf/dtf/discovery.test.ts @@ -5,7 +5,6 @@ import { discoverIndexDtfs, discoverIndexDtfsByChain, discoverIndexDtfsFromSubgraph, - getIndexDtfStatus, } from "@/index-dtf/dtf/discovery"; const DTF = "0x0000000000000000000000000000000000000001"; @@ -37,32 +36,6 @@ describe("Index DTF discovery", () => { ); }); - it("returns API status for a known Index DTF", async () => { - const fetch = vi.fn(async () => - Response.json([{ type: "index", address: DTF, chainId: 8453, status: "deprecated" }]), - ); - vi.stubGlobal("fetch", fetch); - const client = createDtfClient({ apiBaseUrl: "https://api.example" }); - - await expect(getIndexDtfStatus(client, { address: DTF, chainId: 8453 })).resolves.toBe("deprecated"); - // One unpaged scan with an explicit high limit — never a server-capped page. - expect(String((fetch.mock.calls[0] as unknown as [URL])[0])).toBe("https://api.example/discover/dtfs?limit=1000"); - }); - - it("matches status by address and chain in one global discovery request", async () => { - const fetch = vi.fn(async () => - Response.json([ - { type: "index", address: DTF, chainId: 1, status: "deprecated" }, - { type: "index", address: DTF_B, chainId: 8453, status: "unsupported" }, - ]), - ); - vi.stubGlobal("fetch", fetch); - const client = createDtfClient({ apiBaseUrl: "https://api.example" }); - - await expect(getIndexDtfStatus(client, { address: DTF, chainId: 8453 })).resolves.toBe("active"); - expect(fetch).toHaveBeenCalledOnce(); - }); - it("filters chain-scoped discovery to Index DTFs", async () => { const fetch = vi.fn(async () => Response.json([ @@ -193,15 +166,6 @@ describe("Index DTF discovery", () => { expect(currentRequests).toBe(2); }); - it("treats missing discovery status as active like Register", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => Response.json([])), - ); - const client = createDtfClient({ apiBaseUrl: "https://api.example" }); - - await expect(getIndexDtfStatus(client, { address: DTF, chainId: 8453 })).resolves.toBe("active"); - }); }); function subgraphDtf(id: string, symbol: string) { diff --git a/packages/sdk/src/index-dtf/dtf/discovery.ts b/packages/sdk/src/index-dtf/dtf/discovery.ts index fcc1eca..e1f322f 100644 --- a/packages/sdk/src/index-dtf/dtf/discovery.ts +++ b/packages/sdk/src/index-dtf/dtf/discovery.ts @@ -14,10 +14,6 @@ export type DiscoverIndexDtfsParams = { readonly sort?: string; }; -// One unpaged status scan: high enough to cover every listed DTF in a single -// request, so a server-side default page cap can't silently truncate the scan. -const STATUS_SCAN_LIMIT = 1000; - export type DiscoverIndexDtfsByChainParams = Omit & { readonly chainId: SupportedChainId; }; @@ -136,25 +132,6 @@ export async function discoverIndexDtfsFromSubgraph( }); } -/** - * Reads the API-backed active/deprecated/unsupported status for one Index DTF. - */ -export async function getIndexDtfStatus( - client: DtfClient, - params: { readonly address: Address; readonly chainId: SupportedChainId }, -): Promise { - const address = getAddress(params.address); - const items = await fetchDiscoveryItems(client, "/discover/dtfs", { limit: STATUS_SCAN_LIMIT }); - const item = items.find( - (dtf) => - isIndexDiscoveryItem(dtf) && - dtf.chainId === params.chainId && - dtf.address.toLowerCase() === address.toLowerCase(), - ); - - return item?.status ?? "active"; -} - /** * Reads Index DTF statuses in one API request for list/status screens. */ diff --git a/packages/sdk/src/index-dtf/dtf/index.ts b/packages/sdk/src/index-dtf/dtf/index.ts index ea9c0cb..272ed24 100644 --- a/packages/sdk/src/index-dtf/dtf/index.ts +++ b/packages/sdk/src/index-dtf/dtf/index.ts @@ -1,6 +1,5 @@ import type { Address } from "viem"; -import { indexDtfs as indexDtfCatalog } from "@reserve-protocol/dtf-catalog"; import { getAddress } from "viem"; import type { DtfClient } from "@/client"; @@ -33,6 +32,7 @@ import { mapIndexDtfPriceHistory, type IndexDtfBrandResponse, } from "@/index-dtf/dtf/mappers"; +import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; import { GetIndexDtfDocument } from "@/index-dtf/subgraph/dtf.generated"; import { SdkError } from "@/lib/errors"; import { getTokensData } from "@/lib/tokens"; @@ -79,7 +79,7 @@ export async function getFull(client: DtfClient, params: GetIndexDtfParams): Pro fetchedAt: market.timestamp, }, basket: market.basket, - status: indexDtfCatalog[params.chainId]?.[params.address.toLowerCase()]?.status ?? "active", + status: getIndexDtfStatus(params), ...(brand ? { brand } : {}), }; } diff --git a/packages/sdk/src/index-dtf/dtf/status.test.ts b/packages/sdk/src/index-dtf/dtf/status.test.ts new file mode 100644 index 0000000..72bfc07 --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/status.test.ts @@ -0,0 +1,26 @@ +import { getAddress } from "viem"; +import { describe, expect, it } from "vitest"; + +import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; + +// Real catalog entry: VTF on Base, deprecated. +const DEPRECATED_BASE = "0x47686106181b3cefe4eaf94c4c10b48ac750370b"; +const UNKNOWN = "0x0000000000000000000000000000000000000001"; + +describe("getIndexDtfStatus", () => { + it("reads a deprecated entry from the real catalog", () => { + expect(getIndexDtfStatus({ address: DEPRECATED_BASE, chainId: 8453 })).toBe("deprecated"); + }); + + it("matches regardless of address casing", () => { + expect(getIndexDtfStatus({ address: getAddress(DEPRECATED_BASE), chainId: 8453 })).toBe("deprecated"); + }); + + it("treats DTFs absent from the catalog as active", () => { + expect(getIndexDtfStatus({ address: UNKNOWN, chainId: 8453 })).toBe("active"); + }); + + it("scopes the lookup to the requested chain", () => { + expect(getIndexDtfStatus({ address: DEPRECATED_BASE, chainId: 1 })).toBe("active"); + }); +}); diff --git a/packages/sdk/src/index-dtf/dtf/status.ts b/packages/sdk/src/index-dtf/dtf/status.ts new file mode 100644 index 0000000..0741f5d --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/status.ts @@ -0,0 +1,23 @@ +import type { Address } from "viem"; + +import { indexDtfs } from "@reserve-protocol/dtf-catalog"; + +import type { SupportedChainId } from "@/config"; +import type { DtfStatus } from "@/types/common"; + +export type GetIndexDtfStatusParams = { + readonly address: Address; + readonly chainId: SupportedChainId; +}; + +/** + * Reads the curated catalog listing status for one Index DTF. DTFs absent from + * the catalog are active. + */ +export function getIndexDtfStatus(params: GetIndexDtfStatusParams): DtfStatus { + const address = params.address.toLowerCase(); + const entries = Object.entries(indexDtfs[params.chainId] ?? {}); + const entry = entries.find(([key]) => key.toLowerCase() === address)?.[1]; + + return entry?.status ?? "active"; +} diff --git a/packages/sdk/src/index-dtf/index.ts b/packages/sdk/src/index-dtf/index.ts index a3ce0dd..d1592eb 100644 --- a/packages/sdk/src/index-dtf/index.ts +++ b/packages/sdk/src/index-dtf/index.ts @@ -33,9 +33,9 @@ export { discoverIndexDtfs, discoverIndexDtfsByChain, discoverIndexDtfsFromSubgraph, - getIndexDtfStatus, getIndexDtfStatuses, } from "@/index-dtf/dtf/discovery"; +export { getIndexDtfStatus } from "@/index-dtf/dtf/status"; export { getAssetList as getIndexDtfAssetList } from "@/client/api/assets"; export { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; export { getIndexDtfHolders } from "@/index-dtf/dtf/holders"; @@ -283,6 +283,7 @@ export { } from "@/index-dtf/vote-lock/index"; export type * from "@/index-dtf/dtf/discovery"; +export type * from "@/index-dtf/dtf/status"; export type { Asset, GetAssetListParams } from "@/types/api"; export type * from "@/index-dtf/dtf/exposure"; export type * from "@/index-dtf/dtf/holders"; diff --git a/packages/sdk/src/index-dtf/namespace.ts b/packages/sdk/src/index-dtf/namespace.ts index 741eb33..b5e5c15 100644 --- a/packages/sdk/src/index-dtf/namespace.ts +++ b/packages/sdk/src/index-dtf/namespace.ts @@ -28,9 +28,9 @@ import { discoverIndexDtfs, discoverIndexDtfsByChain, discoverIndexDtfsFromSubgraph, - getIndexDtfStatus, getIndexDtfStatuses, } from "@/index-dtf/dtf/discovery"; +import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; import { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; import { getIndexDtfHolders } from "@/index-dtf/dtf/holders"; import { @@ -121,7 +121,7 @@ export function createIndexDtfNamespace(client: DtfClient) { getPrice: (params: GetIndexDtfPriceParams) => getPrice(client, params), getPrices: (params: Parameters[1]) => getPrices(client, params), getPriceHistory: (params: GetIndexDtfPriceHistoryParams) => getPriceHistory(client, params), - getStatus: (params: Parameters[1]) => getIndexDtfStatus(client, params), + getStatus: getIndexDtfStatus, getStatuses: (params?: Parameters[1]) => getIndexDtfStatuses(client, params), getExposure: (params: Parameters[1]) => getIndexDtfExposure(client, params), getHolders: (params: Parameters[1]) => getIndexDtfHolders(client, params), diff --git a/packages/sdk/src/index-dtf/ref.ts b/packages/sdk/src/index-dtf/ref.ts index 8eca33b..828f173 100644 --- a/packages/sdk/src/index-dtf/ref.ts +++ b/packages/sdk/src/index-dtf/ref.ts @@ -9,7 +9,7 @@ import type { GetIndexDtfPriceHistoryOptions, } from "@/types/index-dtf"; -import { getIndexDtfStatus } from "@/index-dtf/dtf/discovery"; +import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; import { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; import { getBasket, @@ -99,7 +99,7 @@ export function createIndexDtfRef(client: DtfClient, params: DtfParams) { getPrice: () => getPrice(client, { address, chainId }), getPriceHistory: (options: GetIndexDtfPriceHistoryOptions) => getPriceHistory(client, { ...options, address, chainId }), - getStatus: () => getIndexDtfStatus(client, { address, chainId }), + getStatus: () => getIndexDtfStatus({ address, chainId }), getExposure: (options: Omit[1], "address" | "chainId"> = {}) => getIndexDtfExposure(client, { ...options, address, chainId }), getTransactions: (options: Omit[1], "address" | "chainId"> = {}) => From 49aeb87b3de1755bc017febc50b99be3879433ae Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 20 Jul 2026 13:24:27 -0700 Subject: [PATCH 11/19] =?UTF-8?q?feat(react-sdk):=20useIndexDtfPerformance?= =?UTF-8?q?=20=E2=80=94=20deduped=20history=20+=20live=20point,=20raw=20ca?= =?UTF-8?q?che=20preserved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/index-dtf-performance-hook.md | 5 + .../use-index-dtf-performance.test.tsx | 76 ++++++++++++++ .../index-dtf/use-index-dtf-performance.ts | 98 +++++++++++++++++++ packages/react-sdk/src/index.ts | 15 +++ 4 files changed, 194 insertions(+) create mode 100644 .changeset/index-dtf-performance-hook.md create mode 100644 packages/react-sdk/src/index-dtf/use-index-dtf-performance.test.tsx create mode 100644 packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts diff --git a/.changeset/index-dtf-performance-hook.md b/.changeset/index-dtf-performance-hook.md new file mode 100644 index 0000000..ff6826b --- /dev/null +++ b/.changeset/index-dtf-performance-hook.md @@ -0,0 +1,5 @@ +--- +"@reserve-protocol/react-sdk": minor +--- + +`useIndexDtfPerformance` — price history composed for display: deduped by timestamp, with a live point appended when `currentPrice` + `currentTotalSupply` are provided (marketCap derived). The cache entry stays the raw point array under the canonical price-history key; composition happens after the cache, so other consumers of the key keep the raw contract. diff --git a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.test.tsx b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.test.tsx new file mode 100644 index 0000000..fdfdc02 --- /dev/null +++ b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.test.tsx @@ -0,0 +1,76 @@ +import { createElement, type PropsWithChildren } from "react"; + +// @vitest-environment jsdom +import type { DtfSdk, IndexDtfPricePoint } from "@reserve-protocol/sdk"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + composeIndexDtfPerformance, + dedupeIndexDtfPricePoints, + useIndexDtfPerformance, +} from "@/index-dtf/use-index-dtf-performance"; +import { DtfSdkProvider } from "@/provider"; +import { dtfQueryKeys } from "@/query-keys"; + +const ADDRESS = "0x00000000000000000000000000000000000000D1"; + +const point = (timestamp: number, price: number): IndexDtfPricePoint => + ({ timestamp, price, marketCap: price * 100, totalSupply: 100, basket: [] }) as unknown as IndexDtfPricePoint; + +describe("dedupeIndexDtfPricePoints", () => { + it("keeps the last occurrence per timestamp, ordered", () => { + const deduped = dedupeIndexDtfPricePoints([point(1, 1), point(2, 2), point(2, 2.5), point(3, 3)]); + + expect(deduped.map((p) => [p.timestamp, p.price])).toEqual([ + [1, 1], + [2, 2.5], + [3, 3], + ]); + }); +}); + +describe("composeIndexDtfPerformance", () => { + it("appends a live point only when price AND supply are provided", () => { + const points = [point(1, 1)]; + + expect(composeIndexDtfPerformance(points, 2)).toHaveLength(1); + expect(composeIndexDtfPerformance(points, undefined, 100)).toHaveLength(1); + + const composed = composeIndexDtfPerformance(points, 2, 100); + expect(composed).toHaveLength(2); + expect(composed[1]).toMatchObject({ price: 2, marketCap: 200, totalSupply: 100 }); + }); +}); + +describe("useIndexDtfPerformance", () => { + it("caches the raw point array; composition never enters the shared cache entry", async () => { + const raw = [point(1, 1), point(1, 1.5), point(2, 2)]; + const sdk = { index: { getPriceHistory: vi.fn(async () => raw) } } as unknown as DtfSdk; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + function wrapper({ children }: PropsWithChildren) { + return createElement( + DtfSdkProvider, + { sdk }, + createElement(QueryClientProvider, { client: queryClient }, children), + ); + } + + const params = { address: ADDRESS, chainId: 8453, from: 0, to: 10, interval: "1h" } as const; + const { result } = renderHook( + () => useIndexDtfPerformance({ ...params, currentPrice: 3, currentTotalSupply: 100 }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Composed for the consumer: deduped + live point. + expect(result.current.data?.map((p) => p.price)).toEqual([1.5, 2, 3]); + // The shared cache entry stays the raw array — currentPrice is not in the + // key and never pollutes it. + expect(queryClient.getQueryData(dtfQueryKeys.index.priceHistory(params))).toEqual(raw); + }); +}); diff --git a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts new file mode 100644 index 0000000..b39fc90 --- /dev/null +++ b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts @@ -0,0 +1,98 @@ +import type { GetIndexDtfPriceHistoryParams, IndexDtfPricePoint } from "@reserve-protocol/sdk"; + +import { useQuery } from "@tanstack/react-query"; +import { useCallback } from "react"; + +import type { DtfQueryOptions } from "@/query"; + +import { useDtfSdk } from "@/provider"; +import { indexDtfPriceHistoryQueryOptions } from "@/query-options"; + +export type IndexDtfPerformancePoint = { + readonly timestamp: number; + readonly price: number; + readonly marketCap?: number; + readonly totalSupply?: number; + readonly basket: IndexDtfPricePoint["basket"]; +}; + +export type UseIndexDtfPerformanceParams = GetIndexDtfPriceHistoryParams & { + readonly currentPrice?: number; + readonly currentTotalSupply?: number; +}; + +// The API occasionally returns duplicated rows for the same timestamp; keep the +// last occurrence. Input is expected in ascending timestamp order (API +// contract) — the fast path returns it untouched. +export function dedupeIndexDtfPricePoints(points: readonly T[]): T[] { + const byTimestamp = new Map(); + for (const point of points) { + byTimestamp.set(point.timestamp, point); + } + if (byTimestamp.size === points.length) { + return [...points]; + } + return [...byTimestamp.values()].sort((a, b) => a.timestamp - b.timestamp); +} + +export function composeIndexDtfPerformance( + points: readonly IndexDtfPricePoint[], + currentPrice?: number, + currentTotalSupply?: number, +): readonly IndexDtfPerformancePoint[] { + const series: IndexDtfPerformancePoint[] = dedupeIndexDtfPricePoints(points); + const now = Math.floor(Date.now() / 1_000); + const last = series[series.length - 1]; + + // Live point needs both inputs — its marketCap is derived, and a point + // without one would understate the freshest value instead of extending it. + if (currentPrice && currentTotalSupply && (!last || now > last.timestamp)) { + series.push({ + timestamp: now, + price: currentPrice, + marketCap: currentPrice * currentTotalSupply, + totalSupply: currentTotalSupply, + basket: [], + }); + } + + return series; +} + +/** + * Price history composed for display: deduped by timestamp, with a live point + * appended when `currentPrice` is provided and extends the series. The cache + * entry stays the raw point array under the canonical price-history key — + * composition happens in `select`, after the cache. + */ +export function useIndexDtfPerformance( + params: UseIndexDtfPerformanceParams | undefined, + options?: Omit< + DtfQueryOptions, + "select" + >, +) { + const sdk = useDtfSdk(); + const currentPrice = params?.currentPrice; + const currentTotalSupply = params?.currentTotalSupply; + const historyParams = params + ? { + address: params.address, + chainId: params.chainId, + from: params.from, + to: params.to, + interval: params.interval, + } + : undefined; + + const select = useCallback( + (points: readonly IndexDtfPricePoint[]) => + composeIndexDtfPerformance(points, currentPrice, currentTotalSupply), + [currentPrice, currentTotalSupply], + ); + + return useQuery({ + ...indexDtfPriceHistoryQueryOptions(sdk, historyParams, options), + select, + }); +} diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index 18ed600..14b2df0 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -210,3 +210,18 @@ export { useIndexDtfVoteLockState, useIndexDtfVoteLockVaultState, } from "@/index-dtf-extra-hooks"; +export { + composeIndexDtfPerformance, + dedupeIndexDtfPricePoints, + useIndexDtfPerformance, +} from "@/index-dtf/use-index-dtf-performance"; +export type { + IndexDtfPerformancePoint, + UseIndexDtfPerformanceParams, +} from "@/index-dtf/use-index-dtf-performance"; +export { useAccountBalancePnl } from "@/index-dtf/use-account-balance-pnl"; +export type { + AccountBalancePnl, + AccountBalancePnlPeriod, + UseAccountBalancePnlParams, +} from "@/index-dtf/use-account-balance-pnl"; From 1baaff1c27fbd33500b63ec0319d33754d4d6cce Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 20 Jul 2026 13:24:27 -0700 Subject: [PATCH 12/19] =?UTF-8?q?feat:=20account=20balance=20PnL=20?= =?UTF-8?q?=E2=80=94=20snapshot=20read=20+=20useAccountBalancePnl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/account-balance-pnl.md | 6 ++ .../use-account-balance-pnl.test.tsx | 71 ++++++++++++ .../src/index-dtf/use-account-balance-pnl.ts | 81 ++++++++++++++ packages/react-sdk/src/query-keys.ts | 2 + .../sdk/src/index-dtf/dtf/account-pnl.test.ts | 74 +++++++++++++ packages/sdk/src/index-dtf/dtf/account-pnl.ts | 102 ++++++++++++++++++ packages/sdk/src/index-dtf/index.ts | 6 ++ packages/sdk/src/index-dtf/namespace.ts | 3 + packages/sdk/src/index.ts | 3 + 9 files changed, 348 insertions(+) create mode 100644 .changeset/account-balance-pnl.md create mode 100644 packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx create mode 100644 packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts create mode 100644 packages/sdk/src/index-dtf/dtf/account-pnl.test.ts create mode 100644 packages/sdk/src/index-dtf/dtf/account-pnl.ts diff --git a/.changeset/account-balance-pnl.md b/.changeset/account-balance-pnl.md new file mode 100644 index 0000000..f610a67 --- /dev/null +++ b/.changeset/account-balance-pnl.md @@ -0,0 +1,6 @@ +--- +"@reserve-protocol/sdk": minor +"@reserve-protocol/react-sdk": minor +--- + +Account balance PnL reads: `getIndexDtfAccountBalanceSnapshot` (carry-forward daily balance snapshot at a mark) plus the pure `selectPriceAtMark`/`calculateAccountBalancePnl` helpers, and `useAccountBalancePnl({ account, dtf, chainId, period })` composing snapshot × price-at-mark against the current position value. `pnl: null` means hide (not holding at the mark or an input unavailable) — never a fabricated figure. diff --git a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx new file mode 100644 index 0000000..1ebfe20 --- /dev/null +++ b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx @@ -0,0 +1,71 @@ +import { createElement, type PropsWithChildren } from "react"; + +// @vitest-environment jsdom +import type { DtfSdk } from "@reserve-protocol/sdk"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { useAccountBalancePnl } from "@/index-dtf/use-account-balance-pnl"; +import { DtfSdkProvider } from "@/provider"; + +const ACCOUNT = "0x00000000000000000000000000000000000000A1"; +const DTF = "0x00000000000000000000000000000000000000D1"; + +function setup(sdkOverrides: { + snapshot: { balance: { raw: bigint; formatted: string }; timestamp: number } | null; + prices?: readonly { timestamp: number; price: number }[]; +}) { + const getAccountBalanceSnapshot = vi.fn(async () => sdkOverrides.snapshot); + const getPriceHistory = vi.fn(async () => sdkOverrides.prices ?? []); + const sdk = { index: { getAccountBalanceSnapshot, getPriceHistory } } as unknown as DtfSdk; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + function wrapper({ children }: PropsWithChildren) { + return createElement(DtfSdkProvider, { sdk }, createElement(QueryClientProvider, { client: queryClient }, children)); + } + + return { wrapper, getPriceHistory }; +} + +const PARAMS = { account: ACCOUNT, dtf: DTF, chainId: 8453, period: "7d", currentValue: 25 } as const; + +describe("useAccountBalancePnl", () => { + it("derives pnl from the snapshot balance and the price at the mark", async () => { + const { wrapper } = setup({ + snapshot: { balance: { raw: 2_000_000_000_000_000_000n, formatted: "2" }, timestamp: 1 }, + prices: [ + { timestamp: 1, price: 0 }, + { timestamp: 2, price: 10 }, + ], + }); + + const { result } = renderHook(() => useAccountBalancePnl(PARAMS), { wrapper }); + + await waitFor(() => expect(result.current.isResolved).toBe(true)); + expect(result.current.pnl).toBe(5); // 25 − 2 × 10 + }); + + it("resolves to a hidden pnl without holding at the mark — no price fetch fired", async () => { + const { wrapper, getPriceHistory } = setup({ snapshot: null }); + + const { result } = renderHook(() => useAccountBalancePnl(PARAMS), { wrapper }); + + await waitFor(() => expect(result.current.isResolved).toBe(true)); + expect(result.current.pnl).toBeNull(); + expect(getPriceHistory).not.toHaveBeenCalled(); + }); + + it("hides pnl when the price window has no real price", async () => { + const { wrapper } = setup({ + snapshot: { balance: { raw: 1n, formatted: "1" }, timestamp: 1 }, + prices: [{ timestamp: 1, price: 0 }], + }); + + const { result } = renderHook(() => useAccountBalancePnl(PARAMS), { wrapper }); + + await waitFor(() => expect(result.current.isResolved).toBe(true)); + expect(result.current.pnl).toBeNull(); + }); +}); diff --git a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts new file mode 100644 index 0000000..969d48f --- /dev/null +++ b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts @@ -0,0 +1,81 @@ +import type { SupportedChainId } from "@reserve-protocol/sdk"; +import type { Address } from "viem"; + +import { calculateAccountBalancePnl, selectPriceAtMark } from "@reserve-protocol/sdk"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { useDtfSdk } from "@/provider"; +import { indexDtfPriceHistoryQueryOptions } from "@/query-options"; +import { dtfQueryKeys } from "@/query-keys"; + +const PERIOD_SECONDS = { + "7d": 7 * 24 * 3_600, +} as const; + +export type AccountBalancePnlPeriod = keyof typeof PERIOD_SECONDS; + +export type UseAccountBalancePnlParams = { + readonly account: Address; + readonly dtf: Address; + readonly chainId: SupportedChainId; + readonly period: AccountBalancePnlPeriod; + /** The position's current USD value; pnl stays null until it's known. */ + readonly currentValue?: number; +}; + +export type AccountBalancePnl = { + /** null = hide: not holding at the mark, or an input is unavailable. */ + readonly pnl: number | null; + /** Every dependent read settled — a null pnl now means "hide", not "loading". */ + readonly isResolved: boolean; +}; + +export function useAccountBalancePnl(params: UseAccountBalancePnlParams | undefined): AccountBalancePnl { + const sdk = useDtfSdk(); + + // Hour-floored so query keys stay stable within the hour. + const mark = useMemo(() => { + const hourFlooredNow = Math.floor(Date.now() / 1_000 / 3_600) * 3_600; + return params ? hourFlooredNow - PERIOD_SECONDS[params.period] : undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params?.period]); + + const snapshotParams = + params && mark ? { account: params.account, dtf: params.dtf, chainId: params.chainId, before: mark } : undefined; + + const snapshot = useQuery({ + queryKey: dtfQueryKeys.index.accountBalanceSnapshot(snapshotParams), + queryFn: () => sdk.index.getAccountBalanceSnapshot(snapshotParams!), + enabled: Boolean(snapshotParams), + }); + + const snapshotBalance = snapshot.data ? Number(snapshot.data.balance.formatted) : null; + const holding = snapshotBalance !== null && snapshotBalance > 0; + + // Shares the canonical raw price-history key; the last-positive selection + // happens here, never inside the shared cache entry. + const priceParams = + params && mark && holding + ? { address: params.dtf, chainId: params.chainId, from: mark - 3_600, to: mark, interval: "1h" as const } + : undefined; + + const price = useQuery({ + ...indexDtfPriceHistoryQueryOptions(sdk, priceParams), + enabled: Boolean(priceParams), + staleTime: Infinity, + }); + + const priceAtMark = price.data ? selectPriceAtMark(price.data) : null; + const snapshotSettled = snapshot.isSuccess || snapshot.isError; + const priceSettled = price.isSuccess || price.isError; + + return { + pnl: calculateAccountBalancePnl({ + snapshotBalance, + priceAtMark, + currentValue: params?.currentValue, + }), + isResolved: snapshotSettled && (!holding || priceSettled), + }; +} diff --git a/packages/react-sdk/src/query-keys.ts b/packages/react-sdk/src/query-keys.ts index 6a7b865..a918e49 100644 --- a/packages/react-sdk/src/query-keys.ts +++ b/packages/react-sdk/src/query-keys.ts @@ -197,6 +197,8 @@ export const dtfQueryKeys = { [...dtfQueryKeys.index.all(), "exposure", keyParams(params)] as const, holders: (params?: IndexMethodParams<"getHolders">) => [...dtfQueryKeys.index.all(), "holders", keyParams(params)] as const, + accountBalanceSnapshot: (params?: IndexMethodParams<"getAccountBalanceSnapshot">) => + [...dtfQueryKeys.index.all(), "account-balance-snapshot", keyParams(params)] as const, transactions: (params?: IndexMethodParams<"getTransactions">) => [...dtfQueryKeys.index.all(), "transactions", keyParams(params)] as const, revenue: (params?: IndexMethodParams<"getRevenue">) => diff --git a/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts b/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts new file mode 100644 index 0000000..c1e23bb --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts @@ -0,0 +1,74 @@ +import type { DtfClient } from "@/client"; + +import { describe, expect, it, vi } from "vitest"; + +import { + calculateAccountBalancePnl, + getIndexDtfAccountBalanceSnapshot, + selectPriceAtMark, +} from "@/index-dtf/dtf/account-pnl"; + +const ACCOUNT = "0x00000000000000000000000000000000000000A1"; +const DTF = "0x00000000000000000000000000000000000000D1"; + +function createClient(rows: readonly { amount: string; timestamp: string }[]) { + const queryIndex = vi.fn(async () => ({ accountBalanceDailySnapshots: rows })); + return { client: { subgraph: { queryIndex } } as unknown as DtfClient, queryIndex }; +} + +describe("getIndexDtfAccountBalanceSnapshot", () => { + it("maps the carry-forward snapshot row", async () => { + const { client, queryIndex } = createClient([{ amount: "2000000000000000000", timestamp: "1700000000" }]); + + const snapshot = await getIndexDtfAccountBalanceSnapshot(client, { + account: ACCOUNT, + dtf: DTF, + chainId: 8453, + before: 1_700_000_100, + }); + + expect(snapshot).toEqual({ balance: { raw: 2_000_000_000_000_000_000n, formatted: "2" }, timestamp: 1_700_000_000 }); + expect(queryIndex).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: 8453, + variables: { account: ACCOUNT.toLowerCase(), token: DTF.toLowerCase(), before: "1700000100" }, + }), + ); + }); + + it("returns null when the wallet has no history at the mark", async () => { + const { client } = createClient([]); + + await expect( + getIndexDtfAccountBalanceSnapshot(client, { account: ACCOUNT, dtf: DTF, chainId: 8453, before: 1 }), + ).resolves.toBeNull(); + }); +}); + +describe("selectPriceAtMark", () => { + it("takes the last point with a real price", () => { + expect(selectPriceAtMark([{ price: 1.1 }, { price: 1.2 }, { price: 0 }])).toBe(1.2); + }); + + it("is null when every point is zero or the window is empty", () => { + expect(selectPriceAtMark([{ price: 0 }, { price: 0 }])).toBeNull(); + expect(selectPriceAtMark([])).toBeNull(); + }); +}); + +describe("calculateAccountBalancePnl", () => { + it("is the value diff against the position's worth at the mark", () => { + expect(calculateAccountBalancePnl({ snapshotBalance: 2, priceAtMark: 10, currentValue: 25 })).toBe(5); + }); + + it("hides when the wallet was not holding at the mark", () => { + expect(calculateAccountBalancePnl({ snapshotBalance: null, priceAtMark: 10, currentValue: 25 })).toBeNull(); + expect(calculateAccountBalancePnl({ snapshotBalance: 0, priceAtMark: 10, currentValue: 25 })).toBeNull(); + }); + + it("hides when an input is unavailable — never fabricates from a 0 price", () => { + expect(calculateAccountBalancePnl({ snapshotBalance: 2, priceAtMark: 0, currentValue: 25 })).toBeNull(); + expect(calculateAccountBalancePnl({ snapshotBalance: 2, priceAtMark: null, currentValue: 25 })).toBeNull(); + expect(calculateAccountBalancePnl({ snapshotBalance: 2, priceAtMark: 10, currentValue: undefined })).toBeNull(); + }); +}); diff --git a/packages/sdk/src/index-dtf/dtf/account-pnl.ts b/packages/sdk/src/index-dtf/dtf/account-pnl.ts new file mode 100644 index 0000000..8452253 --- /dev/null +++ b/packages/sdk/src/index-dtf/dtf/account-pnl.ts @@ -0,0 +1,102 @@ +import { getAddress, type Address } from "viem"; + +import type { DtfClient } from "@/client"; +import type { SupportedChainId } from "@/config"; +import type { Amount } from "@/types/common"; + +import { mapAmount } from "@/lib/utils"; + +export type GetIndexDtfAccountBalanceSnapshotParams = { + readonly account: Address; + readonly dtf: Address; + readonly chainId: SupportedChainId; + /** Unix seconds — the snapshot at or before this mark. */ + readonly before: number; +}; + +export type IndexDtfAccountBalanceSnapshot = { + readonly balance: Amount; + readonly timestamp: number; +}; + +// Daily snapshots only exist for days with balance activity, so the latest row +// at/before the mark is the carry-forward balance the wallet held then. No row +// = the wallet's history is younger than the mark (or it never held). +const SNAPSHOT_QUERY = ` + query IndexDtfAccountBalanceSnapshot($account: String!, $token: String!, $before: BigInt!) { + accountBalanceDailySnapshots( + where: { account: $account, token: $token, timestamp_lte: $before } + orderBy: timestamp + orderDirection: desc + first: 1 + ) { + amount + timestamp + } + } +`; + +type SnapshotResponse = { + readonly accountBalanceDailySnapshots: readonly { readonly amount: string; readonly timestamp: string }[]; +}; + +export async function getIndexDtfAccountBalanceSnapshot( + client: DtfClient, + params: GetIndexDtfAccountBalanceSnapshotParams, +): Promise { + const data = await client.subgraph.queryIndex< + SnapshotResponse, + { account: string; token: string; before: string } + >({ + chainId: params.chainId, + query: SNAPSHOT_QUERY, + variables: { + account: getAddress(params.account).toLowerCase(), + token: getAddress(params.dtf).toLowerCase(), + before: String(params.before), + }, + }); + const snapshot = data.accountBalanceDailySnapshots[0]; + if (!snapshot) { + return null; + } + + return { + balance: mapAmount(snapshot.amount, 18), + timestamp: Number(snapshot.timestamp), + }; +} + +/** Last point at/before the mark with a real price — windows can carry leading zero-price rows. */ +export function selectPriceAtMark(points: readonly { readonly price: number }[]): number | null { + for (let i = points.length - 1; i >= 0; i--) { + const price = points[i]!.price; + if (price > 0) { + return price; + } + } + + return null; +} + +/** + * Position value now minus its value at the mark (snapshot balance × price + * then). Deliberately a plain value diff — deposits/withdrawals inside the + * window show up in it. null = hide: the wallet wasn't holding at the mark, or + * an input is unavailable. + */ +export function calculateAccountBalancePnl(inputs: { + readonly snapshotBalance: number | null; + readonly priceAtMark: number | null | undefined; + readonly currentValue: number | undefined; +}): number | null { + const { snapshotBalance, priceAtMark, currentValue } = inputs; + if (snapshotBalance === null || snapshotBalance <= 0) { + return null; + } + if (!priceAtMark || priceAtMark <= 0 || currentValue === undefined) { + return null; + } + + return currentValue - snapshotBalance * priceAtMark; +} diff --git a/packages/sdk/src/index-dtf/index.ts b/packages/sdk/src/index-dtf/index.ts index d1592eb..45633df 100644 --- a/packages/sdk/src/index-dtf/index.ts +++ b/packages/sdk/src/index-dtf/index.ts @@ -38,6 +38,11 @@ export { export { getIndexDtfStatus } from "@/index-dtf/dtf/status"; export { getAssetList as getIndexDtfAssetList } from "@/client/api/assets"; export { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; +export { + calculateAccountBalancePnl, + getIndexDtfAccountBalanceSnapshot, + selectPriceAtMark, +} from "@/index-dtf/dtf/account-pnl"; export { getIndexDtfHolders } from "@/index-dtf/dtf/holders"; export { getIndexDtfIssuanceState, @@ -286,6 +291,7 @@ export type * from "@/index-dtf/dtf/discovery"; export type * from "@/index-dtf/dtf/status"; export type { Asset, GetAssetListParams } from "@/types/api"; export type * from "@/index-dtf/dtf/exposure"; +export type * from "@/index-dtf/dtf/account-pnl"; export type * from "@/index-dtf/dtf/holders"; export type * from "@/index-dtf/dtf/issuance"; export type * from "@/index-dtf/dtf/revenue"; diff --git a/packages/sdk/src/index-dtf/namespace.ts b/packages/sdk/src/index-dtf/namespace.ts index b5e5c15..bb61628 100644 --- a/packages/sdk/src/index-dtf/namespace.ts +++ b/packages/sdk/src/index-dtf/namespace.ts @@ -32,6 +32,7 @@ import { } from "@/index-dtf/dtf/discovery"; import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; import { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; +import { getIndexDtfAccountBalanceSnapshot } from "@/index-dtf/dtf/account-pnl"; import { getIndexDtfHolders } from "@/index-dtf/dtf/holders"; import { getBasket, @@ -125,6 +126,8 @@ export function createIndexDtfNamespace(client: DtfClient) { getStatuses: (params?: Parameters[1]) => getIndexDtfStatuses(client, params), getExposure: (params: Parameters[1]) => getIndexDtfExposure(client, params), getHolders: (params: Parameters[1]) => getIndexDtfHolders(client, params), + getAccountBalanceSnapshot: (params: Parameters[1]) => + getIndexDtfAccountBalanceSnapshot(client, params), getTransactions: (params: Parameters[1]) => getIndexDtfTransactions(client, params), getBidsEnabled: (params: Parameters[1]) => getIndexDtfBidsEnabled(client, params), getRebalanceControl: (params: Parameters[1]) => diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e76f644..77b3afe 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -141,6 +141,9 @@ export { getIndexDtfExposure, getIndexDtfGuardians, getIndexDtfCatalogEntries, + calculateAccountBalancePnl, + getIndexDtfAccountBalanceSnapshot, + selectPriceAtMark, getIndexDtfHolders, getIndexDtfIssuanceState, getIndexDtfLegacyVoteLocks, From 6f9009feae7f1743c0257f32c3244b21c40f0971 Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 20 Jul 2026 13:24:42 -0700 Subject: [PATCH 13/19] style: oxfmt on the new pnl/performance modules --- .../src/index-dtf/use-account-balance-pnl.test.tsx | 6 +++++- .../src/index-dtf/use-account-balance-pnl.ts | 5 +++-- .../src/index-dtf/use-index-dtf-performance.ts | 11 ++++------- packages/sdk/src/index-dtf/dtf/account-pnl.test.ts | 9 ++++++--- packages/sdk/src/index-dtf/dtf/account-pnl.ts | 5 +---- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx index 1ebfe20..59dd8a2 100644 --- a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx +++ b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx @@ -23,7 +23,11 @@ function setup(sdkOverrides: { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); function wrapper({ children }: PropsWithChildren) { - return createElement(DtfSdkProvider, { sdk }, createElement(QueryClientProvider, { client: queryClient }, children)); + return createElement( + DtfSdkProvider, + { sdk }, + createElement(QueryClientProvider, { client: queryClient }, children), + ); } return { wrapper, getPriceHistory }; diff --git a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts index 969d48f..71f18ff 100644 --- a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts +++ b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts @@ -1,13 +1,14 @@ +import { useMemo } from "react"; + import type { SupportedChainId } from "@reserve-protocol/sdk"; import type { Address } from "viem"; import { calculateAccountBalancePnl, selectPriceAtMark } from "@reserve-protocol/sdk"; import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; import { useDtfSdk } from "@/provider"; -import { indexDtfPriceHistoryQueryOptions } from "@/query-options"; import { dtfQueryKeys } from "@/query-keys"; +import { indexDtfPriceHistoryQueryOptions } from "@/query-options"; const PERIOD_SECONDS = { "7d": 7 * 24 * 3_600, diff --git a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts index b39fc90..b6a01a1 100644 --- a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts +++ b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts @@ -1,7 +1,8 @@ +import { useCallback } from "react"; + import type { GetIndexDtfPriceHistoryParams, IndexDtfPricePoint } from "@reserve-protocol/sdk"; import { useQuery } from "@tanstack/react-query"; -import { useCallback } from "react"; import type { DtfQueryOptions } from "@/query"; @@ -67,10 +68,7 @@ export function composeIndexDtfPerformance( */ export function useIndexDtfPerformance( params: UseIndexDtfPerformanceParams | undefined, - options?: Omit< - DtfQueryOptions, - "select" - >, + options?: Omit, "select">, ) { const sdk = useDtfSdk(); const currentPrice = params?.currentPrice; @@ -86,8 +84,7 @@ export function useIndexDtfPerformance( : undefined; const select = useCallback( - (points: readonly IndexDtfPricePoint[]) => - composeIndexDtfPerformance(points, currentPrice, currentTotalSupply), + (points: readonly IndexDtfPricePoint[]) => composeIndexDtfPerformance(points, currentPrice, currentTotalSupply), [currentPrice, currentTotalSupply], ); diff --git a/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts b/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts index c1e23bb..342c65c 100644 --- a/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts +++ b/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts @@ -1,7 +1,7 @@ -import type { DtfClient } from "@/client"; - import { describe, expect, it, vi } from "vitest"; +import type { DtfClient } from "@/client"; + import { calculateAccountBalancePnl, getIndexDtfAccountBalanceSnapshot, @@ -27,7 +27,10 @@ describe("getIndexDtfAccountBalanceSnapshot", () => { before: 1_700_000_100, }); - expect(snapshot).toEqual({ balance: { raw: 2_000_000_000_000_000_000n, formatted: "2" }, timestamp: 1_700_000_000 }); + expect(snapshot).toEqual({ + balance: { raw: 2_000_000_000_000_000_000n, formatted: "2" }, + timestamp: 1_700_000_000, + }); expect(queryIndex).toHaveBeenCalledWith( expect.objectContaining({ chainId: 8453, diff --git a/packages/sdk/src/index-dtf/dtf/account-pnl.ts b/packages/sdk/src/index-dtf/dtf/account-pnl.ts index 8452253..5749e1e 100644 --- a/packages/sdk/src/index-dtf/dtf/account-pnl.ts +++ b/packages/sdk/src/index-dtf/dtf/account-pnl.ts @@ -44,10 +44,7 @@ export async function getIndexDtfAccountBalanceSnapshot( client: DtfClient, params: GetIndexDtfAccountBalanceSnapshotParams, ): Promise { - const data = await client.subgraph.queryIndex< - SnapshotResponse, - { account: string; token: string; before: string } - >({ + const data = await client.subgraph.queryIndex({ chainId: params.chainId, query: SNAPSHOT_QUERY, variables: { From e44ec05bb51133294d8efd2c14f5d377db403553 Mon Sep 17 00:00:00 2001 From: Luis Date: Mon, 20 Jul 2026 13:26:38 -0700 Subject: [PATCH 14/19] fix(react-sdk): performance point carries required marketCap/totalSupply (raw contract) --- packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts index b6a01a1..ed2767e 100644 --- a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts +++ b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts @@ -12,8 +12,8 @@ import { indexDtfPriceHistoryQueryOptions } from "@/query-options"; export type IndexDtfPerformancePoint = { readonly timestamp: number; readonly price: number; - readonly marketCap?: number; - readonly totalSupply?: number; + readonly marketCap: number; + readonly totalSupply: number; readonly basket: IndexDtfPricePoint["basket"]; }; From f1ee8f56f9164e559fdabc48920001219dd09471 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 21 Jul 2026 08:43:31 -0700 Subject: [PATCH 15/19] =?UTF-8?q?refactor:=20three-tier=20boundary=20?= =?UTF-8?q?=E2=80=94=20snapshot=20primitive=20hook,=20PnL=20semantics=20ou?= =?UTF-8?q?t=20of=20the=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdk keeps transport + data-hygiene primitives (snapshot read, selectPriceAtMark); react-sdk is strictly the hook-per-read access layer, gaining useIndexDtfAccountBalanceSnapshot and usePrefetchIndexDtfPriceHistory (so apps never touch the sdk client directly). useAccountBalancePnl and calculateAccountBalancePnl are deleted — a product metric like week-ago PnL is app-side composition; its semantics change on design revisions, not protocol versions, so it doesn't belong here. --- .changeset/account-balance-pnl.md | 2 +- packages/react-sdk/src/hooks.ts | 23 +++++- .../react-sdk/src/index-dtf-extra-hooks.ts | 9 ++ .../react-sdk/src/index-dtf-query-options.ts | 15 ++++ .../use-account-balance-pnl.test.tsx | 75 ----------------- .../src/index-dtf/use-account-balance-pnl.ts | 82 ------------------- packages/react-sdk/src/index.ts | 14 +--- .../sdk/src/index-dtf/dtf/account-pnl.test.ts | 23 +----- packages/sdk/src/index-dtf/dtf/account-pnl.ts | 22 ----- packages/sdk/src/index-dtf/index.ts | 6 +- packages/sdk/src/index.ts | 1 - 11 files changed, 53 insertions(+), 219 deletions(-) delete mode 100644 packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx delete mode 100644 packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts diff --git a/.changeset/account-balance-pnl.md b/.changeset/account-balance-pnl.md index f610a67..976f0d7 100644 --- a/.changeset/account-balance-pnl.md +++ b/.changeset/account-balance-pnl.md @@ -3,4 +3,4 @@ "@reserve-protocol/react-sdk": minor --- -Account balance PnL reads: `getIndexDtfAccountBalanceSnapshot` (carry-forward daily balance snapshot at a mark) plus the pure `selectPriceAtMark`/`calculateAccountBalancePnl` helpers, and `useAccountBalancePnl({ account, dtf, chainId, period })` composing snapshot × price-at-mark against the current position value. `pnl: null` means hide (not holding at the mark or an input unavailable) — never a fabricated figure. +Account-balance snapshot primitives: `getIndexDtfAccountBalanceSnapshot` (carry-forward daily balance at a mark) with the `selectPriceAtMark` data-hygiene helper, exposed through `useIndexDtfAccountBalanceSnapshot` on the hook layer, plus `usePrefetchIndexDtfPriceHistory` so apps can warm sibling price-history windows without touching the sdk client. Product metrics (e.g. a week-ago PnL) compose these app-side — the SDK deliberately ships no PnL semantics. diff --git a/packages/react-sdk/src/hooks.ts b/packages/react-sdk/src/hooks.ts index 48b79c8..8c5cdbd 100644 --- a/packages/react-sdk/src/hooks.ts +++ b/packages/react-sdk/src/hooks.ts @@ -1,3 +1,5 @@ +import { useCallback } from "react"; + import type { BuildIndexDtfBasketProposalParams, BuildIndexDtfBasketSettingsProposalParams, @@ -21,7 +23,7 @@ import type { ListIndexDtfsParams, } from "@reserve-protocol/sdk"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useDtfSdk } from "@/provider"; import { @@ -120,6 +122,25 @@ export function useIndexDtfPriceHistory( return useQuery(indexDtfPriceHistoryQueryOptions(sdk, params, options)); } +/** + * Prefetch a price-history window under its canonical key (e.g. the chart's + * sibling time ranges) — the hook-layer primitive so apps never reach for the + * sdk client directly. + */ +export function usePrefetchIndexDtfPriceHistory() { + const sdk = useDtfSdk(); + const queryClient = useQueryClient(); + + return useCallback( + (params: GetIndexDtfPriceHistoryParams, staleTime?: number) => + queryClient.prefetchQuery({ + ...indexDtfPriceHistoryQueryOptions(sdk, params), + ...(staleTime !== undefined ? { staleTime } : {}), + }), + [queryClient, sdk], + ); +} + export function useBuildIndexDtfBasketProposal( params: BuildIndexDtfBasketProposalParams | undefined, options?: DtfQueryOptions, diff --git a/packages/react-sdk/src/index-dtf-extra-hooks.ts b/packages/react-sdk/src/index-dtf-extra-hooks.ts index 509d432..49e5c40 100644 --- a/packages/react-sdk/src/index-dtf-extra-hooks.ts +++ b/packages/react-sdk/src/index-dtf-extra-hooks.ts @@ -14,6 +14,7 @@ import { indexDtfCompletedRebalanceQueryOptions, indexDtfCompletedRebalancesQueryOptions, indexDtfCurrentRebalanceQueryOptions, + indexDtfAccountBalanceSnapshotQueryOptions, indexDtfExposureQueryOptions, indexDtfHoldersQueryOptions, indexDtfIssuanceStateQueryOptions, @@ -81,6 +82,14 @@ export function useIndexDtfHolders>>( + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + const sdk = useDtfSdk(); + return useQuery(indexDtfAccountBalanceSnapshotQueryOptions(sdk, params, options)); +} + export function useIndexDtfTransactions>>( 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 6ba6be3..a2ed717 100644 --- a/packages/react-sdk/src/index-dtf-query-options.ts +++ b/packages/react-sdk/src/index-dtf-query-options.ts @@ -66,6 +66,21 @@ export function indexDtfExposureQueryOptions>, +>( + sdk: DtfSdk, + params: MethodParams> | undefined, + options?: DtfQueryOptions>, TData>, +) { + return createDtfQueryOptions( + dtfQueryKeys.index.accountBalanceSnapshot(params), + () => sdk.index.getAccountBalanceSnapshot(requireParams(params, "indexDtfAccountBalanceSnapshotQueryOptions")), + params !== undefined, + options, + ); +} + export function indexDtfHoldersQueryOptions>>( sdk: DtfSdk, params: MethodParams> | undefined, diff --git a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx deleted file mode 100644 index 59dd8a2..0000000 --- a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { createElement, type PropsWithChildren } from "react"; - -// @vitest-environment jsdom -import type { DtfSdk } from "@reserve-protocol/sdk"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { useAccountBalancePnl } from "@/index-dtf/use-account-balance-pnl"; -import { DtfSdkProvider } from "@/provider"; - -const ACCOUNT = "0x00000000000000000000000000000000000000A1"; -const DTF = "0x00000000000000000000000000000000000000D1"; - -function setup(sdkOverrides: { - snapshot: { balance: { raw: bigint; formatted: string }; timestamp: number } | null; - prices?: readonly { timestamp: number; price: number }[]; -}) { - const getAccountBalanceSnapshot = vi.fn(async () => sdkOverrides.snapshot); - const getPriceHistory = vi.fn(async () => sdkOverrides.prices ?? []); - const sdk = { index: { getAccountBalanceSnapshot, getPriceHistory } } as unknown as DtfSdk; - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - - function wrapper({ children }: PropsWithChildren) { - return createElement( - DtfSdkProvider, - { sdk }, - createElement(QueryClientProvider, { client: queryClient }, children), - ); - } - - return { wrapper, getPriceHistory }; -} - -const PARAMS = { account: ACCOUNT, dtf: DTF, chainId: 8453, period: "7d", currentValue: 25 } as const; - -describe("useAccountBalancePnl", () => { - it("derives pnl from the snapshot balance and the price at the mark", async () => { - const { wrapper } = setup({ - snapshot: { balance: { raw: 2_000_000_000_000_000_000n, formatted: "2" }, timestamp: 1 }, - prices: [ - { timestamp: 1, price: 0 }, - { timestamp: 2, price: 10 }, - ], - }); - - const { result } = renderHook(() => useAccountBalancePnl(PARAMS), { wrapper }); - - await waitFor(() => expect(result.current.isResolved).toBe(true)); - expect(result.current.pnl).toBe(5); // 25 − 2 × 10 - }); - - it("resolves to a hidden pnl without holding at the mark — no price fetch fired", async () => { - const { wrapper, getPriceHistory } = setup({ snapshot: null }); - - const { result } = renderHook(() => useAccountBalancePnl(PARAMS), { wrapper }); - - await waitFor(() => expect(result.current.isResolved).toBe(true)); - expect(result.current.pnl).toBeNull(); - expect(getPriceHistory).not.toHaveBeenCalled(); - }); - - it("hides pnl when the price window has no real price", async () => { - const { wrapper } = setup({ - snapshot: { balance: { raw: 1n, formatted: "1" }, timestamp: 1 }, - prices: [{ timestamp: 1, price: 0 }], - }); - - const { result } = renderHook(() => useAccountBalancePnl(PARAMS), { wrapper }); - - await waitFor(() => expect(result.current.isResolved).toBe(true)); - expect(result.current.pnl).toBeNull(); - }); -}); diff --git a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts b/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts deleted file mode 100644 index 71f18ff..0000000 --- a/packages/react-sdk/src/index-dtf/use-account-balance-pnl.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useMemo } from "react"; - -import type { SupportedChainId } from "@reserve-protocol/sdk"; -import type { Address } from "viem"; - -import { calculateAccountBalancePnl, selectPriceAtMark } from "@reserve-protocol/sdk"; -import { useQuery } from "@tanstack/react-query"; - -import { useDtfSdk } from "@/provider"; -import { dtfQueryKeys } from "@/query-keys"; -import { indexDtfPriceHistoryQueryOptions } from "@/query-options"; - -const PERIOD_SECONDS = { - "7d": 7 * 24 * 3_600, -} as const; - -export type AccountBalancePnlPeriod = keyof typeof PERIOD_SECONDS; - -export type UseAccountBalancePnlParams = { - readonly account: Address; - readonly dtf: Address; - readonly chainId: SupportedChainId; - readonly period: AccountBalancePnlPeriod; - /** The position's current USD value; pnl stays null until it's known. */ - readonly currentValue?: number; -}; - -export type AccountBalancePnl = { - /** null = hide: not holding at the mark, or an input is unavailable. */ - readonly pnl: number | null; - /** Every dependent read settled — a null pnl now means "hide", not "loading". */ - readonly isResolved: boolean; -}; - -export function useAccountBalancePnl(params: UseAccountBalancePnlParams | undefined): AccountBalancePnl { - const sdk = useDtfSdk(); - - // Hour-floored so query keys stay stable within the hour. - const mark = useMemo(() => { - const hourFlooredNow = Math.floor(Date.now() / 1_000 / 3_600) * 3_600; - return params ? hourFlooredNow - PERIOD_SECONDS[params.period] : undefined; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [params?.period]); - - const snapshotParams = - params && mark ? { account: params.account, dtf: params.dtf, chainId: params.chainId, before: mark } : undefined; - - const snapshot = useQuery({ - queryKey: dtfQueryKeys.index.accountBalanceSnapshot(snapshotParams), - queryFn: () => sdk.index.getAccountBalanceSnapshot(snapshotParams!), - enabled: Boolean(snapshotParams), - }); - - const snapshotBalance = snapshot.data ? Number(snapshot.data.balance.formatted) : null; - const holding = snapshotBalance !== null && snapshotBalance > 0; - - // Shares the canonical raw price-history key; the last-positive selection - // happens here, never inside the shared cache entry. - const priceParams = - params && mark && holding - ? { address: params.dtf, chainId: params.chainId, from: mark - 3_600, to: mark, interval: "1h" as const } - : undefined; - - const price = useQuery({ - ...indexDtfPriceHistoryQueryOptions(sdk, priceParams), - enabled: Boolean(priceParams), - staleTime: Infinity, - }); - - const priceAtMark = price.data ? selectPriceAtMark(price.data) : null; - const snapshotSettled = snapshot.isSuccess || snapshot.isError; - const priceSettled = price.isSuccess || price.isError; - - return { - pnl: calculateAccountBalancePnl({ - snapshotBalance, - priceAtMark, - currentValue: params?.currentValue, - }), - isResolved: snapshotSettled && (!holding || priceSettled), - }; -} diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index 14b2df0..83d3e0b 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -86,6 +86,7 @@ export { useIndexDtfList, useIndexDtfPrice, useIndexDtfPriceHistory, + usePrefetchIndexDtfPriceHistory, useIndexDtfVersion, } from "@/hooks"; export { @@ -100,6 +101,7 @@ export { indexDtfCompletedRebalancesQueryOptions, indexDtfCurrentRebalanceQueryOptions, indexDtfExposureQueryOptions, + indexDtfAccountBalanceSnapshotQueryOptions, indexDtfHoldersQueryOptions, indexDtfIssuanceStateQueryOptions, indexDtfLatestAuctionQueryOptions, @@ -191,6 +193,7 @@ export { useAccountPortfolio, useIndexDtfCurrentRebalance, useIndexDtfExposure, + useIndexDtfAccountBalanceSnapshot, useIndexDtfHolders, useIndexDtfIssuanceState, useIndexDtfLatestAuction, @@ -215,13 +218,4 @@ export { dedupeIndexDtfPricePoints, useIndexDtfPerformance, } from "@/index-dtf/use-index-dtf-performance"; -export type { - IndexDtfPerformancePoint, - UseIndexDtfPerformanceParams, -} from "@/index-dtf/use-index-dtf-performance"; -export { useAccountBalancePnl } from "@/index-dtf/use-account-balance-pnl"; -export type { - AccountBalancePnl, - AccountBalancePnlPeriod, - UseAccountBalancePnlParams, -} from "@/index-dtf/use-account-balance-pnl"; +export type { IndexDtfPerformancePoint, UseIndexDtfPerformanceParams } from "@/index-dtf/use-index-dtf-performance"; diff --git a/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts b/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts index 342c65c..96c363c 100644 --- a/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts +++ b/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { DtfClient } from "@/client"; -import { - calculateAccountBalancePnl, - getIndexDtfAccountBalanceSnapshot, - selectPriceAtMark, -} from "@/index-dtf/dtf/account-pnl"; +import { getIndexDtfAccountBalanceSnapshot, selectPriceAtMark } from "@/index-dtf/dtf/account-pnl"; const ACCOUNT = "0x00000000000000000000000000000000000000A1"; const DTF = "0x00000000000000000000000000000000000000D1"; @@ -58,20 +54,3 @@ describe("selectPriceAtMark", () => { expect(selectPriceAtMark([])).toBeNull(); }); }); - -describe("calculateAccountBalancePnl", () => { - it("is the value diff against the position's worth at the mark", () => { - expect(calculateAccountBalancePnl({ snapshotBalance: 2, priceAtMark: 10, currentValue: 25 })).toBe(5); - }); - - it("hides when the wallet was not holding at the mark", () => { - expect(calculateAccountBalancePnl({ snapshotBalance: null, priceAtMark: 10, currentValue: 25 })).toBeNull(); - expect(calculateAccountBalancePnl({ snapshotBalance: 0, priceAtMark: 10, currentValue: 25 })).toBeNull(); - }); - - it("hides when an input is unavailable — never fabricates from a 0 price", () => { - expect(calculateAccountBalancePnl({ snapshotBalance: 2, priceAtMark: 0, currentValue: 25 })).toBeNull(); - expect(calculateAccountBalancePnl({ snapshotBalance: 2, priceAtMark: null, currentValue: 25 })).toBeNull(); - expect(calculateAccountBalancePnl({ snapshotBalance: 2, priceAtMark: 10, currentValue: undefined })).toBeNull(); - }); -}); diff --git a/packages/sdk/src/index-dtf/dtf/account-pnl.ts b/packages/sdk/src/index-dtf/dtf/account-pnl.ts index 5749e1e..5337bef 100644 --- a/packages/sdk/src/index-dtf/dtf/account-pnl.ts +++ b/packages/sdk/src/index-dtf/dtf/account-pnl.ts @@ -75,25 +75,3 @@ export function selectPriceAtMark(points: readonly { readonly price: number }[]) return null; } - -/** - * Position value now minus its value at the mark (snapshot balance × price - * then). Deliberately a plain value diff — deposits/withdrawals inside the - * window show up in it. null = hide: the wallet wasn't holding at the mark, or - * an input is unavailable. - */ -export function calculateAccountBalancePnl(inputs: { - readonly snapshotBalance: number | null; - readonly priceAtMark: number | null | undefined; - readonly currentValue: number | undefined; -}): number | null { - const { snapshotBalance, priceAtMark, currentValue } = inputs; - if (snapshotBalance === null || snapshotBalance <= 0) { - return null; - } - if (!priceAtMark || priceAtMark <= 0 || currentValue === undefined) { - return null; - } - - return currentValue - snapshotBalance * priceAtMark; -} diff --git a/packages/sdk/src/index-dtf/index.ts b/packages/sdk/src/index-dtf/index.ts index 45633df..b9a03ff 100644 --- a/packages/sdk/src/index-dtf/index.ts +++ b/packages/sdk/src/index-dtf/index.ts @@ -38,11 +38,7 @@ export { export { getIndexDtfStatus } from "@/index-dtf/dtf/status"; export { getAssetList as getIndexDtfAssetList } from "@/client/api/assets"; export { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; -export { - calculateAccountBalancePnl, - getIndexDtfAccountBalanceSnapshot, - selectPriceAtMark, -} from "@/index-dtf/dtf/account-pnl"; +export { getIndexDtfAccountBalanceSnapshot, selectPriceAtMark } from "@/index-dtf/dtf/account-pnl"; export { getIndexDtfHolders } from "@/index-dtf/dtf/holders"; export { getIndexDtfIssuanceState, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 77b3afe..9ddcb40 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -141,7 +141,6 @@ export { getIndexDtfExposure, getIndexDtfGuardians, getIndexDtfCatalogEntries, - calculateAccountBalancePnl, getIndexDtfAccountBalanceSnapshot, selectPriceAtMark, getIndexDtfHolders, From 869bd8c7353112916f3dd1fbcdc8f54b4a988fb0 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 23 Jul 2026 08:05:57 -0700 Subject: [PATCH 16/19] fix: 0.5.0 release-audit corrections - Live performance point accepts zero price/supply (defined-ness gate, not truthiness) with zero-vector regressions; prefetch covered by test. - selectPriceAtMark is timestamp-aware with an optional mark (defaults to the old latest-positive behavior); snapshot read wired through namespace, ref, query options and hook, with a hook test. - getIndexDtfStatus validates/normalizes the address before the catalog lookup. - Brand normalization + governance control-flow cleanup; fresh GraphQL output. - LICENSE shipped in all package tarballs. --- .changeset/account-balance-pnl.md | 2 +- .changeset/status-sync-catalog.md | 2 +- LICENSE | 21 ++++++ packages/dtf-catalog/LICENSE | 21 ++++++ packages/react-sdk/LICENSE | 21 ++++++ packages/react-sdk/README.md | 4 +- .../react-sdk/src/index-dtf-extra-hooks.ts | 1 + .../use-index-dtf-performance.test.tsx | 40 ++++++++++- .../index-dtf/use-index-dtf-performance.ts | 16 +++-- packages/react-sdk/src/query-options.test.ts | 9 +++ ...ndex-dtf-account-balance-snapshot.test.tsx | 66 +++++++++++++++++++ packages/sdk/LICENSE | 21 ++++++ .../sdk/src/index-dtf/dtf/account-pnl.test.ts | 36 ++++++++-- packages/sdk/src/index-dtf/dtf/account-pnl.ts | 18 +++-- .../sdk/src/index-dtf/dtf/discovery.test.ts | 7 +- .../sdk/src/index-dtf/dtf/exposure.test.ts | 18 ++++- packages/sdk/src/index-dtf/dtf/index.test.ts | 14 +++- packages/sdk/src/index-dtf/dtf/mappers.ts | 2 +- .../src/index-dtf/dtf/platform-fee.test.ts | 5 +- .../src/index-dtf/dtf/price-history.test.ts | 1 - packages/sdk/src/index-dtf/dtf/status.test.ts | 6 +- packages/sdk/src/index-dtf/dtf/status.ts | 5 +- .../sdk/src/index-dtf/governance/utils.ts | 14 ++-- packages/sdk/src/index-dtf/namespace.test.ts | 29 +++++++- packages/sdk/src/index-dtf/namespace.ts | 4 +- .../sdk/src/index-dtf/rebalance/index.test.ts | 4 +- packages/sdk/src/index-dtf/ref.ts | 6 +- .../src/index-dtf/subgraph/dtf.generated.ts | 28 ++++++++ packages/sdk/src/yield-dtf/governance.ts | 11 ++-- 29 files changed, 370 insertions(+), 62 deletions(-) create mode 100644 LICENSE create mode 100644 packages/dtf-catalog/LICENSE create mode 100644 packages/react-sdk/LICENSE create mode 100644 packages/react-sdk/src/use-index-dtf-account-balance-snapshot.test.tsx create mode 100644 packages/sdk/LICENSE diff --git a/.changeset/account-balance-pnl.md b/.changeset/account-balance-pnl.md index 976f0d7..50ba398 100644 --- a/.changeset/account-balance-pnl.md +++ b/.changeset/account-balance-pnl.md @@ -3,4 +3,4 @@ "@reserve-protocol/react-sdk": minor --- -Account-balance snapshot primitives: `getIndexDtfAccountBalanceSnapshot` (carry-forward daily balance at a mark) with the `selectPriceAtMark` data-hygiene helper, exposed through `useIndexDtfAccountBalanceSnapshot` on the hook layer, plus `usePrefetchIndexDtfPriceHistory` so apps can warm sibling price-history windows without touching the sdk client. Product metrics (e.g. a week-ago PnL) compose these app-side — the SDK deliberately ships no PnL semantics. +Account-balance snapshot primitives: `getIndexDtfAccountBalanceSnapshot` (carry-forward daily balance at a mark) on the namespace and bound ref, with `selectPriceAtMark(points, mark?)` selecting the latest positive timestamped price at or before an optional mark while preserving its one-argument behavior. The read is exposed through `useIndexDtfAccountBalanceSnapshot`, plus `usePrefetchIndexDtfPriceHistory` lets apps warm sibling price-history windows without touching the SDK client. Product metrics (e.g. a week-ago PnL) compose these app-side — the SDK deliberately ships no PnL semantics. diff --git a/.changeset/status-sync-catalog.md b/.changeset/status-sync-catalog.md index 9ed25c5..69ff489 100644 --- a/.changeset/status-sync-catalog.md +++ b/.changeset/status-sync-catalog.md @@ -3,4 +3,4 @@ "@reserve-protocol/react-sdk": minor --- -Breaking: `getIndexDtfStatus` is now a synchronous catalog lookup — `getIndexDtfStatus({ address, chainId }): DtfStatus` reads `@reserve-protocol/dtf-catalog` directly (case-insensitive address match, absent entries are `active`) instead of scanning the `/discover/dtfs` endpoint. `useIndexDtfStatus({ address, chainId })` returns the status directly with no react-query involved; `indexDtfStatusQueryOptions` and the `dtfQueryKeys.index.status` key are removed. +Breaking: `getIndexDtfStatus` is now a synchronous catalog lookup — `getIndexDtfStatus({ address, chainId }): DtfStatus` validates the address and reads `@reserve-protocol/dtf-catalog` directly (case-insensitive address match, absent entries are `active`) instead of scanning the `/discover/dtfs` endpoint. The plural `getIndexDtfStatuses` remains an asynchronous Reserve API discovery projection for bulk/list screens. `useIndexDtfStatus({ address, chainId })` returns the singular status directly with no react-query involved; `indexDtfStatusQueryOptions` and the `dtfQueryKeys.index.status` key are removed. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a2b29c1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ABC Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dtf-catalog/LICENSE b/packages/dtf-catalog/LICENSE new file mode 100644 index 0000000..a2b29c1 --- /dev/null +++ b/packages/dtf-catalog/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ABC Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/react-sdk/LICENSE b/packages/react-sdk/LICENSE new file mode 100644 index 0000000..a2b29c1 --- /dev/null +++ b/packages/react-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ABC Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index b008610..02e8e68 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -37,7 +37,7 @@ import { useIndexDtfPrice } from "@reserve-protocol/react-sdk"; const price = useIndexDtfPrice({ address, chainId }); ``` -Core read surfaces expose matching `*QueryOptions` exports for loaders, prefetching, `useQueries`, and direct query-client use. Required params accept `undefined` and disable the query. Query keys normalize addresses, bigints, object order, and omitted values. +Query-backed core reads expose matching `*QueryOptions` exports for loaders, prefetching, `useQueries`, and direct query-client use. Required params accept `undefined` and disable the query. Query keys normalize addresses, bigints, object order, and omitted values. `useIndexDtfStatus` is a synchronous catalog lookup, so it returns the status directly and has no query options or cache key. Freshness defaults: @@ -45,7 +45,7 @@ Freshness defaults: - normal governance/account state: 30 seconds; - identity, brand, version, catalog, and immutable decode data: 5 minutes. -All hooks accept TanStack Query options, including `select` and an explicit `staleTime` override. +Query-backed hooks accept TanStack Query options, including `select` and an explicit `staleTime` override. `useIndexDtfPerformance` owns its `select` composition to preserve the raw price-history cache, but accepts the remaining query options. ## Bundle shape diff --git a/packages/react-sdk/src/index-dtf-extra-hooks.ts b/packages/react-sdk/src/index-dtf-extra-hooks.ts index 49e5c40..fcc600d 100644 --- a/packages/react-sdk/src/index-dtf-extra-hooks.ts +++ b/packages/react-sdk/src/index-dtf-extra-hooks.ts @@ -82,6 +82,7 @@ export function useIndexDtfHolders>>( params: MethodParams> | undefined, options?: DtfQueryOptions>, TData>, diff --git a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.test.tsx b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.test.tsx index fdfdc02..19b34d8 100644 --- a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.test.tsx +++ b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.test.tsx @@ -1,4 +1,4 @@ -import { createElement, type PropsWithChildren } from "react"; +import { act, createElement, type PropsWithChildren } from "react"; // @vitest-environment jsdom import type { DtfSdk, IndexDtfPricePoint } from "@reserve-protocol/sdk"; @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { usePrefetchIndexDtfPriceHistory } from "@/hooks"; import { composeIndexDtfPerformance, dedupeIndexDtfPricePoints, @@ -42,6 +43,14 @@ describe("composeIndexDtfPerformance", () => { const composed = composeIndexDtfPerformance(points, 2, 100); expect(composed).toHaveLength(2); expect(composed[1]).toMatchObject({ price: 2, marketCap: 200, totalSupply: 100 }); + + const zeroSupply = composeIndexDtfPerformance(points, 2, 0); + expect(zeroSupply).toHaveLength(2); + expect(zeroSupply[1]).toMatchObject({ price: 2, marketCap: 0, totalSupply: 0 }); + + const zeroPrice = composeIndexDtfPerformance(points, 0, 100); + expect(zeroPrice).toHaveLength(2); + expect(zeroPrice[1]).toMatchObject({ price: 0, marketCap: 0, totalSupply: 100 }); }); }); @@ -73,4 +82,33 @@ describe("useIndexDtfPerformance", () => { // key and never pollutes it. expect(queryClient.getQueryData(dtfQueryKeys.index.priceHistory(params))).toEqual(raw); }); + + it("prefetches price history under the canonical key with the requested freshness", async () => { + const raw = [point(1, 1)]; + const sdk = { index: { getPriceHistory: vi.fn(async () => raw) } } as unknown as DtfSdk; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const prefetchQuery = vi.spyOn(queryClient, "prefetchQuery"); + + function wrapper({ children }: PropsWithChildren) { + return createElement( + DtfSdkProvider, + { sdk }, + createElement(QueryClientProvider, { client: queryClient }, children), + ); + } + + const params = { address: ADDRESS, chainId: 8453, from: 0, to: 10, interval: "1h" } as const; + const { result } = renderHook(() => usePrefetchIndexDtfPriceHistory(), { wrapper }); + + await act(() => result.current(params, 60_000)); + + expect(prefetchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: dtfQueryKeys.index.priceHistory(params), + staleTime: 60_000, + }), + ); + expect(sdk.index.getPriceHistory).toHaveBeenCalledWith(params); + expect(queryClient.getQueryData(dtfQueryKeys.index.priceHistory(params))).toEqual(raw); + }); }); diff --git a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts index ed2767e..a7167ba 100644 --- a/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts +++ b/packages/react-sdk/src/index-dtf/use-index-dtf-performance.ts @@ -22,9 +22,10 @@ export type UseIndexDtfPerformanceParams = GetIndexDtfPriceHistoryParams & { readonly currentTotalSupply?: number; }; -// The API occasionally returns duplicated rows for the same timestamp; keep the -// last occurrence. Input is expected in ascending timestamp order (API -// contract) — the fast path returns it untouched. +/** + * Keeps the last API row for each timestamp. API input is expected in + * ascending order; already-unique input preserves that order without sorting. + */ export function dedupeIndexDtfPricePoints(points: readonly T[]): T[] { const byTimestamp = new Map(); for (const point of points) { @@ -36,6 +37,7 @@ export function dedupeIndexDtfPricePoints a.timestamp - b.timestamp); } +/** Composes deduped history with a live point when both live inputs are defined. */ export function composeIndexDtfPerformance( points: readonly IndexDtfPricePoint[], currentPrice?: number, @@ -47,7 +49,7 @@ export function composeIndexDtfPerformance( // Live point needs both inputs — its marketCap is derived, and a point // without one would understate the freshest value instead of extending it. - if (currentPrice && currentTotalSupply && (!last || now > last.timestamp)) { + if (currentPrice !== undefined && currentTotalSupply !== undefined && (!last || now > last.timestamp)) { series.push({ timestamp: now, price: currentPrice, @@ -62,9 +64,9 @@ export function composeIndexDtfPerformance( /** * Price history composed for display: deduped by timestamp, with a live point - * appended when `currentPrice` is provided and extends the series. The cache - * entry stays the raw point array under the canonical price-history key — - * composition happens in `select`, after the cache. + * appended when both live inputs are defined and extend the series. Zero is a + * valid resolved value. The cache entry stays the raw point array under the + * canonical price-history key — composition happens in `select`, after cache. */ export function useIndexDtfPerformance( params: UseIndexDtfPerformanceParams | undefined, diff --git a/packages/react-sdk/src/query-options.test.ts b/packages/react-sdk/src/query-options.test.ts index 2674552..4c22cda 100644 --- a/packages/react-sdk/src/query-options.test.ts +++ b/packages/react-sdk/src/query-options.test.ts @@ -7,6 +7,7 @@ import { accountPortfolioHistoryQueryOptions, accountPortfolioQueryOptions, accountPortfolioTransactionsQueryOptions, + indexDtfAccountBalanceSnapshotQueryOptions, indexDtfActiveAuctionQueryOptions, indexDtfApprovedRevenueTokensQueryOptions, indexDtfBidQuoteQueryOptions, @@ -207,6 +208,14 @@ const extraIndexQueryOptions: readonly QueryOptionCase[] = [ params: { address: DTF, chainId: 1, limit: 20 }, result: { holders: [], totalHolders: 0 }, }, + { + name: "account balance snapshot", + method: "getAccountBalanceSnapshot", + build: indexDtfAccountBalanceSnapshotQueryOptions, + key: dtfQueryKeys.index.accountBalanceSnapshot, + params: { account: ACCOUNT, dtf: DTF, chainId: 1, before: 1_700_000_000 }, + result: { balance: { raw: 1n, formatted: "0.000000000000000001" }, timestamp: 1_700_000_000 }, + }, { name: "bids enabled", method: "getBidsEnabled", diff --git a/packages/react-sdk/src/use-index-dtf-account-balance-snapshot.test.tsx b/packages/react-sdk/src/use-index-dtf-account-balance-snapshot.test.tsx new file mode 100644 index 0000000..74d414e --- /dev/null +++ b/packages/react-sdk/src/use-index-dtf-account-balance-snapshot.test.tsx @@ -0,0 +1,66 @@ +import { createElement, type PropsWithChildren } from "react"; + +// @vitest-environment jsdom +import type { DtfSdk, IndexDtfAccountBalanceSnapshot } from "@reserve-protocol/sdk"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { useIndexDtfAccountBalanceSnapshot } from "@/index-dtf-extra-hooks"; +import { DtfSdkProvider } from "@/provider"; +import { dtfQueryKeys } from "@/query-keys"; + +const PARAMS = { + account: "0x00000000000000000000000000000000000000A1", + dtf: "0x00000000000000000000000000000000000000D1", + chainId: 8453, + before: 1_700_000_000, +} as const; + +const SNAPSHOT: IndexDtfAccountBalanceSnapshot = { + balance: { raw: 2_000_000_000_000_000_000n, formatted: "2" }, + timestamp: 1_699_999_900, +}; + +describe("useIndexDtfAccountBalanceSnapshot", () => { + it("keeps the raw snapshot under the canonical key while applying caller select", async () => { + const getAccountBalanceSnapshot = vi.fn(async () => SNAPSHOT); + const sdk = { index: { getAccountBalanceSnapshot } } as unknown as DtfSdk; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + const { result } = renderHook( + () => useIndexDtfAccountBalanceSnapshot(PARAMS, { select: (snapshot) => snapshot?.timestamp }), + { wrapper: createWrapper(sdk, queryClient) }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBe(SNAPSHOT.timestamp); + expect(getAccountBalanceSnapshot).toHaveBeenCalledWith(PARAMS); + expect(queryClient.getQueryData(dtfQueryKeys.index.accountBalanceSnapshot(PARAMS))).toEqual(SNAPSHOT); + }); + + it("stays disabled until all params exist", () => { + const getAccountBalanceSnapshot = vi.fn(); + const sdk = { index: { getAccountBalanceSnapshot } } as unknown as DtfSdk; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + const { result } = renderHook(() => useIndexDtfAccountBalanceSnapshot(undefined), { + wrapper: createWrapper(sdk, queryClient), + }); + + expect(result.current.fetchStatus).toBe("idle"); + expect(getAccountBalanceSnapshot).not.toHaveBeenCalled(); + }); +}); + +function createWrapper(sdk: DtfSdk, queryClient: QueryClient) { + return function wrapper({ children }: PropsWithChildren) { + return createElement( + DtfSdkProvider, + { sdk }, + createElement(QueryClientProvider, { client: queryClient }, children), + ); + }; +} diff --git a/packages/sdk/LICENSE b/packages/sdk/LICENSE new file mode 100644 index 0000000..a2b29c1 --- /dev/null +++ b/packages/sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ABC Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts b/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts index 96c363c..15e5878 100644 --- a/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts +++ b/packages/sdk/src/index-dtf/dtf/account-pnl.test.ts @@ -45,12 +45,40 @@ describe("getIndexDtfAccountBalanceSnapshot", () => { }); describe("selectPriceAtMark", () => { - it("takes the last point with a real price", () => { - expect(selectPriceAtMark([{ price: 1.1 }, { price: 1.2 }, { price: 0 }])).toBe(1.2); + it("takes the latest real price at or before the mark regardless of input order", () => { + expect( + selectPriceAtMark( + [ + { timestamp: 300, price: 1.3 }, + { timestamp: 100, price: 1.1 }, + { timestamp: 200, price: 1.2 }, + { timestamp: 250, price: 0 }, + ], + 250, + ), + ).toBe(1.2); }); it("is null when every point is zero or the window is empty", () => { - expect(selectPriceAtMark([{ price: 0 }, { price: 0 }])).toBeNull(); - expect(selectPriceAtMark([])).toBeNull(); + expect( + selectPriceAtMark( + [ + { timestamp: 100, price: 0 }, + { timestamp: 200, price: 0 }, + ], + 200, + ), + ).toBeNull(); + expect(selectPriceAtMark([], 200)).toBeNull(); + }); + + it("keeps the one-argument call compatible by selecting the latest real price", () => { + expect( + selectPriceAtMark([ + { timestamp: 100, price: 1.1 }, + { timestamp: 300, price: 1.3 }, + { timestamp: 200, price: 1.2 }, + ]), + ).toBe(1.3); }); }); diff --git a/packages/sdk/src/index-dtf/dtf/account-pnl.ts b/packages/sdk/src/index-dtf/dtf/account-pnl.ts index 5337bef..499d33e 100644 --- a/packages/sdk/src/index-dtf/dtf/account-pnl.ts +++ b/packages/sdk/src/index-dtf/dtf/account-pnl.ts @@ -64,14 +64,18 @@ export async function getIndexDtfAccountBalanceSnapshot( }; } -/** Last point at/before the mark with a real price — windows can carry leading zero-price rows. */ -export function selectPriceAtMark(points: readonly { readonly price: number }[]): number | null { - for (let i = points.length - 1; i >= 0; i--) { - const price = points[i]!.price; - if (price > 0) { - return price; +/** Latest real price at or before the requested unix-second mark. */ +export function selectPriceAtMark( + points: readonly { readonly price: number; readonly timestamp: number }[], + mark = Number.POSITIVE_INFINITY, +): number | null { + let selected: { readonly price: number; readonly timestamp: number } | undefined; + + for (const point of points) { + if (point.timestamp <= mark && point.price > 0 && (!selected || point.timestamp > selected.timestamp)) { + selected = point; } } - return null; + return selected?.price ?? null; } diff --git a/packages/sdk/src/index-dtf/dtf/discovery.test.ts b/packages/sdk/src/index-dtf/dtf/discovery.test.ts index 8fa51f1..00cb6c9 100644 --- a/packages/sdk/src/index-dtf/dtf/discovery.test.ts +++ b/packages/sdk/src/index-dtf/dtf/discovery.test.ts @@ -1,11 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createDtfClient } from "@/client"; -import { - discoverIndexDtfs, - discoverIndexDtfsByChain, - discoverIndexDtfsFromSubgraph, -} from "@/index-dtf/dtf/discovery"; +import { discoverIndexDtfs, discoverIndexDtfsByChain, discoverIndexDtfsFromSubgraph } from "@/index-dtf/dtf/discovery"; const DTF = "0x0000000000000000000000000000000000000001"; const TOKEN = "0x0000000000000000000000000000000000000002"; @@ -165,7 +161,6 @@ describe("Index DTF discovery", () => { await expect(discoverIndexDtfsFromSubgraph(client, { chainId: 8453 })).resolves.toHaveLength(1); expect(currentRequests).toBe(2); }); - }); function subgraphDtf(id: string, symbol: string) { diff --git a/packages/sdk/src/index-dtf/dtf/exposure.test.ts b/packages/sdk/src/index-dtf/dtf/exposure.test.ts index ff8cd3a..abf7ba3 100644 --- a/packages/sdk/src/index-dtf/dtf/exposure.test.ts +++ b/packages/sdk/src/index-dtf/dtf/exposure.test.ts @@ -11,11 +11,25 @@ describe("getIndexDtfExposure", () => { it("checksums token addresses and preserves the exposure/collateral market caps", async () => { const get = vi.fn(async () => [ { - native: { symbol: "AAPL", name: "Apple", logo: "a.svg", caip2: "eip155:1", coingeckoId: "apple", marketCap: 3_000_000 }, + native: { + symbol: "AAPL", + name: "Apple", + logo: "a.svg", + caip2: "eip155:1", + coingeckoId: "apple", + marketCap: 3_000_000, + }, totalWeight: 0.4, marketCap: 500, tokens: [ - { address: TOKEN, symbol: "uAAPL", weight: 0.4, marketCap: 500, underlyingMarketCap: 3_000_000, bridge: { id: "b", name: "Bridge", url: "u", description: "d", logo: "l", risks: [], wrappedVersion: true } }, + { + address: TOKEN, + symbol: "uAAPL", + weight: 0.4, + marketCap: 500, + underlyingMarketCap: 3_000_000, + bridge: { id: "b", name: "Bridge", url: "u", description: "d", logo: "l", risks: [], wrappedVersion: true }, + }, ], }, ]); diff --git a/packages/sdk/src/index-dtf/dtf/index.test.ts b/packages/sdk/src/index-dtf/dtf/index.test.ts index f14dca4..7e01da5 100644 --- a/packages/sdk/src/index-dtf/dtf/index.test.ts +++ b/packages/sdk/src/index-dtf/dtf/index.test.ts @@ -13,7 +13,7 @@ import { getPrice, getPriceHistory, } from "@/index-dtf/dtf/index"; -import { mapIndexDtf } from "@/index-dtf/dtf/mappers"; +import { mapIndexDtf, mapIndexDtfBrand } from "@/index-dtf/dtf/mappers"; describe("Index DTF getters", () => { afterEach(() => { @@ -371,6 +371,18 @@ describe("Index DTF getters", () => { }); }); + it("normalizes unit-based brand basket types", () => { + const brand = mapIndexDtfBrand({ + status: "ok", + parsedData: { + hidden: false, + dtf: { basketType: " Unit-Based " }, + }, + }); + + expect(brand?.dtf.basketType).toBe("unit-based"); + }); + it("fetches and maps API-backed Index DTF price history", async () => { const fetch = vi.fn(async () => Response.json({ diff --git a/packages/sdk/src/index-dtf/dtf/mappers.ts b/packages/sdk/src/index-dtf/dtf/mappers.ts index 345e8e3..500b87e 100644 --- a/packages/sdk/src/index-dtf/dtf/mappers.ts +++ b/packages/sdk/src/index-dtf/dtf/mappers.ts @@ -197,7 +197,7 @@ export function mapIndexDtfBrand(response: IndexDtfBrandResponse): IndexDtfBrand name: file.name ?? "", })), tags: dtf?.tags ?? [], - basketType: dtf?.basketType === "unit-based" ? "unit-based" : "percentage-based", + basketType: nonEmpty(dtf?.basketType)?.toLowerCase() === "unit-based" ? "unit-based" : "percentage-based", }, creator: mapBrandProfile(creator), curator: mapBrandProfile(curator), diff --git a/packages/sdk/src/index-dtf/dtf/platform-fee.test.ts b/packages/sdk/src/index-dtf/dtf/platform-fee.test.ts index 25ce022..cda7430 100644 --- a/packages/sdk/src/index-dtf/dtf/platform-fee.test.ts +++ b/packages/sdk/src/index-dtf/dtf/platform-fee.test.ts @@ -10,10 +10,7 @@ const REGISTRY = "0x0000000000000000000000000000000000000002"; const RECIPIENT = "0x0000000000000000000000000000000000000003"; function createClient(feeDetails: readonly [string, bigint, bigint, bigint]): DtfClient { - const readContract = vi - .fn() - .mockResolvedValueOnce(REGISTRY) - .mockResolvedValueOnce(feeDetails); + const readContract = vi.fn().mockResolvedValueOnce(REGISTRY).mockResolvedValueOnce(feeDetails); return { viem: { readContract } } as unknown as DtfClient; } diff --git a/packages/sdk/src/index-dtf/dtf/price-history.test.ts b/packages/sdk/src/index-dtf/dtf/price-history.test.ts index 517d8b0..d28d4e5 100644 --- a/packages/sdk/src/index-dtf/dtf/price-history.test.ts +++ b/packages/sdk/src/index-dtf/dtf/price-history.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createDtfClient } from "@/client"; - import { getPriceHistory } from "@/index-dtf/dtf"; const DTF = "0x0000000000000000000000000000000000000001"; diff --git a/packages/sdk/src/index-dtf/dtf/status.test.ts b/packages/sdk/src/index-dtf/dtf/status.test.ts index 72bfc07..09d16f9 100644 --- a/packages/sdk/src/index-dtf/dtf/status.test.ts +++ b/packages/sdk/src/index-dtf/dtf/status.test.ts @@ -1,4 +1,4 @@ -import { getAddress } from "viem"; +import { getAddress, type Address } from "viem"; import { describe, expect, it } from "vitest"; import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; @@ -23,4 +23,8 @@ describe("getIndexDtfStatus", () => { it("scopes the lookup to the requested chain", () => { expect(getIndexDtfStatus({ address: DEPRECATED_BASE, chainId: 1 })).toBe("active"); }); + + it("rejects malformed addresses instead of reporting an active product", () => { + expect(() => getIndexDtfStatus({ address: "not-an-address" as Address, chainId: 8453 })).toThrow(); + }); }); diff --git a/packages/sdk/src/index-dtf/dtf/status.ts b/packages/sdk/src/index-dtf/dtf/status.ts index 0741f5d..4baa1a7 100644 --- a/packages/sdk/src/index-dtf/dtf/status.ts +++ b/packages/sdk/src/index-dtf/dtf/status.ts @@ -1,6 +1,5 @@ -import type { Address } from "viem"; - import { indexDtfs } from "@reserve-protocol/dtf-catalog"; +import { getAddress, type Address } from "viem"; import type { SupportedChainId } from "@/config"; import type { DtfStatus } from "@/types/common"; @@ -15,7 +14,7 @@ export type GetIndexDtfStatusParams = { * the catalog are active. */ export function getIndexDtfStatus(params: GetIndexDtfStatusParams): DtfStatus { - const address = params.address.toLowerCase(); + const address = getAddress(params.address).toLowerCase(); const entries = Object.entries(indexDtfs[params.chainId] ?? {}); const entry = entries.find(([key]) => key.toLowerCase() === address)?.[1]; diff --git a/packages/sdk/src/index-dtf/governance/utils.ts b/packages/sdk/src/index-dtf/governance/utils.ts index 5a1e10a..fe2e6e9 100644 --- a/packages/sdk/src/index-dtf/governance/utils.ts +++ b/packages/sdk/src/index-dtf/governance/utils.ts @@ -125,16 +125,16 @@ export function getProposalState(proposal: ProposalVoteStateInput, timestamp = g state.state = "CANCELED"; } else if (optimisticVetoReached) { state.state = "DEFEATED"; - } else if (timestamp > proposal.voteEnd && isOptimistic) { - state.state = getOptimisticFinalState(proposal, optimisticVetoThresholdVotes); - } else if (timestamp <= proposal.voteEnd) { - state.state = "ACTIVE"; - state.deadline = proposal.voteEnd - timestamp; - } else { + } else if (timestamp > proposal.voteEnd) { // WHY: past the deadline FolioGovernor.state() derives Defeated/Succeeded // from the vote counts even if the ACTIVE transition was never indexed — // a stale PENDING is never EXPIRED (that is a queue-lifecycle state). - state.state = getStandardFinalState(proposal); + state.state = isOptimistic + ? getOptimisticFinalState(proposal, optimisticVetoThresholdVotes) + : getStandardFinalState(proposal); + } else { + state.state = "ACTIVE"; + state.deadline = proposal.voteEnd - timestamp; } } else if (proposal.state === "ACTIVE") { if (optimisticTransitioned) { diff --git a/packages/sdk/src/index-dtf/namespace.test.ts b/packages/sdk/src/index-dtf/namespace.test.ts index b1a7b6c..8b5ea29 100644 --- a/packages/sdk/src/index-dtf/namespace.test.ts +++ b/packages/sdk/src/index-dtf/namespace.test.ts @@ -2,7 +2,7 @@ import type { PublicClient } from "viem"; import { describe, expect, it, vi } from "vitest"; -import { createDtfClient } from "@/client"; +import { createDtfClient, type DtfClient } from "@/client"; import { createIndexDtfNamespace, createIndexDtfRef } from "@/index-dtf/index"; describe("Index DTF namespace", () => { @@ -63,6 +63,33 @@ describe("Index DTF namespace", () => { expect(readContract).toHaveBeenCalledTimes(3); }); + it("binds account balance snapshots to the ref DTF identity", async () => { + const queryIndex = vi.fn(async () => ({ + accountBalanceDailySnapshots: [{ amount: "2000000000000000000", timestamp: "1700000000" }], + })); + const dtf = createIndexDtfRef({ subgraph: { queryIndex } } as unknown as DtfClient, { + address: "0x0000000000000000000000000000000000000001", + chainId: 8453, + }); + + await expect( + dtf.getAccountBalanceSnapshot({ + account: "0x0000000000000000000000000000000000000002", + before: 1_700_000_100, + }), + ).resolves.toMatchObject({ balance: { raw: 2_000_000_000_000_000_000n }, timestamp: 1_700_000_000 }); + expect(queryIndex).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: 8453, + variables: { + account: "0x0000000000000000000000000000000000000002", + token: "0x0000000000000000000000000000000000000001", + before: "1700000100", + }, + }), + ); + }); + it("binds ref proposal vote calls to the ref chain", () => { const dtf = createIndexDtfRef({} as never, { address: "0x0000000000000000000000000000000000000001", diff --git a/packages/sdk/src/index-dtf/namespace.ts b/packages/sdk/src/index-dtf/namespace.ts index bb61628..ebb44e0 100644 --- a/packages/sdk/src/index-dtf/namespace.ts +++ b/packages/sdk/src/index-dtf/namespace.ts @@ -24,15 +24,14 @@ import { prepareIndexDtfDeployPlan, prepareIndexDtfDeployStakingToken, } from "@/index-dtf/deploy/index"; +import { getIndexDtfAccountBalanceSnapshot } from "@/index-dtf/dtf/account-pnl"; import { discoverIndexDtfs, discoverIndexDtfsByChain, discoverIndexDtfsFromSubgraph, getIndexDtfStatuses, } from "@/index-dtf/dtf/discovery"; -import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; import { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; -import { getIndexDtfAccountBalanceSnapshot } from "@/index-dtf/dtf/account-pnl"; import { getIndexDtfHolders } from "@/index-dtf/dtf/holders"; import { getBasket, @@ -65,6 +64,7 @@ import { getIndexDtfRevenue, prepareIndexDtfDistributeFees, } from "@/index-dtf/dtf/revenue"; +import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; import { getIndexDtfTransactions } from "@/index-dtf/dtf/transactions"; import { createIndexDtfGovernanceNamespace } from "@/index-dtf/governance/namespace"; import { getIndexDtfCatalogEntries, listIndexDtfs, resolveIndexDtfAlias } from "@/index-dtf/protocol/index"; diff --git a/packages/sdk/src/index-dtf/rebalance/index.test.ts b/packages/sdk/src/index-dtf/rebalance/index.test.ts index 8fed944..c4da50a 100644 --- a/packages/sdk/src/index-dtf/rebalance/index.test.ts +++ b/packages/sdk/src/index-dtf/rebalance/index.test.ts @@ -23,8 +23,6 @@ describe("getCompletedRebalance", () => { expect(detail.totalRebalancedUsd).toBe(50); expect(detail.avgPriceImpactPercent).toBe(0.3); expect(detail.auctions).toEqual([]); - expect(getIndexDtfRebalanceDetail).toHaveBeenCalledWith( - expect.objectContaining({ chainId: 1, nonce: 4 }), - ); + expect(getIndexDtfRebalanceDetail).toHaveBeenCalledWith(expect.objectContaining({ chainId: 1, nonce: 4 })); }); }); diff --git a/packages/sdk/src/index-dtf/ref.ts b/packages/sdk/src/index-dtf/ref.ts index 828f173..1509c26 100644 --- a/packages/sdk/src/index-dtf/ref.ts +++ b/packages/sdk/src/index-dtf/ref.ts @@ -9,7 +9,7 @@ import type { GetIndexDtfPriceHistoryOptions, } from "@/types/index-dtf"; -import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; +import { getIndexDtfAccountBalanceSnapshot } from "@/index-dtf/dtf/account-pnl"; import { getIndexDtfExposure } from "@/index-dtf/dtf/exposure"; import { getBasket, @@ -40,6 +40,7 @@ import { getIndexDtfRevenue, prepareIndexDtfDistributeFees, } from "@/index-dtf/dtf/revenue"; +import { getIndexDtfStatus } from "@/index-dtf/dtf/status"; import { getIndexDtfTransactions } from "@/index-dtf/dtf/transactions"; import { createIndexDtfGovernanceRef } from "@/index-dtf/governance/ref"; import { @@ -102,6 +103,9 @@ export function createIndexDtfRef(client: DtfClient, params: DtfParams) { getStatus: () => getIndexDtfStatus({ address, chainId }), getExposure: (options: Omit[1], "address" | "chainId"> = {}) => getIndexDtfExposure(client, { ...options, address, chainId }), + getAccountBalanceSnapshot: ( + options: Omit[1], "dtf" | "chainId">, + ) => getIndexDtfAccountBalanceSnapshot(client, { ...options, dtf: address, chainId }), getTransactions: (options: Omit[1], "address" | "chainId"> = {}) => getIndexDtfTransactions(client, { ...options, address, chainId }), getBidsEnabled: (options?: BlockNumberOption | BlockNumber) => diff --git a/packages/sdk/src/index-dtf/subgraph/dtf.generated.ts b/packages/sdk/src/index-dtf/subgraph/dtf.generated.ts index 5cf42d2..ced0db2 100644 --- a/packages/sdk/src/index-dtf/subgraph/dtf.generated.ts +++ b/packages/sdk/src/index-dtf/subgraph/dtf.generated.ts @@ -1155,6 +1155,7 @@ export type Dtf_OrderBy = | 'stToken__id' | 'stToken__optimisticDelegatedVotes' | 'stToken__optimisticDelegatedVotesRaw' + | 'stToken__tokenJar' | 'stToken__totalDelegates' | 'stToken__totalOptimisticDelegates' | 'timestamp' @@ -1641,6 +1642,7 @@ export type Delegate_OrderBy = | 'token__id' | 'token__optimisticDelegatedVotes' | 'token__optimisticDelegatedVotesRaw' + | 'token__tokenJar' | 'token__totalDelegates' | 'token__totalOptimisticDelegates' | 'votes'; @@ -2188,6 +2190,7 @@ export type Governance_OrderBy = | 'token__id' | 'token__optimisticDelegatedVotes' | 'token__optimisticDelegatedVotesRaw' + | 'token__tokenJar' | 'token__totalDelegates' | 'token__totalOptimisticDelegates' | 'version' @@ -2406,6 +2409,7 @@ export type Lock_OrderBy = | 'token__id' | 'token__optimisticDelegatedVotes' | 'token__optimisticDelegatedVotesRaw' + | 'token__tokenJar' | 'token__totalDelegates' | 'token__totalOptimisticDelegates' | 'unlockTime'; @@ -4094,6 +4098,7 @@ export type RewardClaim_OrderBy = | 'token__id' | 'token__optimisticDelegatedVotes' | 'token__optimisticDelegatedVotesRaw' + | 'token__tokenJar' | 'token__totalDelegates' | 'token__totalOptimisticDelegates' | 'txnHash'; @@ -4185,6 +4190,7 @@ export type StakingTokenRewards_OrderBy = | 'stToken__id' | 'stToken__optimisticDelegatedVotes' | 'stToken__optimisticDelegatedVotesRaw' + | 'stToken__tokenJar' | 'stToken__totalDelegates' | 'stToken__totalOptimisticDelegates'; @@ -4281,6 +4287,26 @@ export type StakingToken_Filter = { or?: InputMaybe>>; rewards_?: InputMaybe; token?: InputMaybe; + tokenJar?: InputMaybe; + tokenJar_contains?: InputMaybe; + tokenJar_contains_nocase?: InputMaybe; + tokenJar_ends_with?: InputMaybe; + tokenJar_ends_with_nocase?: InputMaybe; + tokenJar_gt?: InputMaybe; + tokenJar_gte?: InputMaybe; + tokenJar_in?: InputMaybe>; + tokenJar_lt?: InputMaybe; + tokenJar_lte?: InputMaybe; + tokenJar_not?: InputMaybe; + tokenJar_not_contains?: InputMaybe; + tokenJar_not_contains_nocase?: InputMaybe; + tokenJar_not_ends_with?: InputMaybe; + tokenJar_not_ends_with_nocase?: InputMaybe; + tokenJar_not_in?: InputMaybe>; + tokenJar_not_starts_with?: InputMaybe; + tokenJar_not_starts_with_nocase?: InputMaybe; + tokenJar_starts_with?: InputMaybe; + tokenJar_starts_with_nocase?: InputMaybe; token_?: InputMaybe; token_contains?: InputMaybe; token_contains_nocase?: InputMaybe; @@ -4374,6 +4400,7 @@ export type StakingToken_OrderBy = | 'optimisticDelegatedVotesRaw' | 'rewards' | 'token' + | 'tokenJar' | 'token__address' | 'token__burnCount' | 'token__cumulativeHolderCount' @@ -6049,6 +6076,7 @@ export type UnstakingManager_OrderBy = | 'token__id' | 'token__optimisticDelegatedVotes' | 'token__optimisticDelegatedVotesRaw' + | 'token__tokenJar' | 'token__totalDelegates' | 'token__totalOptimisticDelegates'; diff --git a/packages/sdk/src/yield-dtf/governance.ts b/packages/sdk/src/yield-dtf/governance.ts index 1ab9834..2280cb2 100644 --- a/packages/sdk/src/yield-dtf/governance.ts +++ b/packages/sdk/src/yield-dtf/governance.ts @@ -152,12 +152,11 @@ export async function getYieldDtfProposals( }); const summaries = proposals.map((proposal) => mapProposalSummary(proposal, params.chainId)); - // Proposal state is live chain state: one latest block serves both clock - // domains (timestamp for Anastasius, number for Alexios), fetched once per - // request and never the consumer machine's wall clock. - const needsTimepoint = summaries.some( - (summary) => summary.state === "PENDING" || summary.state === "ACTIVE", - ); + // One latest block serves both clock domains (timestamp for Anastasius, + // number for Alexios), fetched once per request. Vote totals remain indexed, + // so list state is eventually consistent near the deadline; detail reads the + // authoritative governor state. + const needsTimepoint = summaries.some((summary) => summary.state === "PENDING" || summary.state === "ACTIVE"); const block = needsTimepoint ? await client.viem.getPublicClient(params.chainId).getBlock() : undefined; return summaries.map((summary) => ({ From 0419eee94b41e65fbe6b71b475aa8951d42f7a49 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 23 Jul 2026 08:05:57 -0700 Subject: [PATCH 17/19] ci: publish workflow runs the complete release gate; codegen freshness enforced --- .github/workflows/ci.yml | 3 +++ .github/workflows/publish.yml | 7 +------ package.json | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01b5dc5..c9ef45b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: - name: Format Check run: pnpm format:check + - name: GraphQL Codegen Check + run: pnpm graphql:codegen:check + - name: Documentation Links run: pnpm docs:links diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 39ece27..381aeb9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -39,14 +39,9 @@ jobs: - name: Install Dependencies run: pnpm install --frozen-lockfile - - name: Validate Manual Release - if: ${{ github.event_name == 'workflow_dispatch' }} + - name: Validate Release run: pnpm release:ci - - name: Build Release Artifacts - if: ${{ github.event_name != 'workflow_dispatch' }} - run: pnpm build - - name: Changeset Release id: changesets uses: changesets/action@v1 diff --git a/package.json b/package.json index 152b497..e9129e2 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "changeset": "changeset", "version": "changeset version", "release": "changeset publish", - "release:ci": "pnpm typecheck && pnpm test && pnpm build && pnpm --filter @reserve-protocol/docs check:sdk-bundle && pnpm docs:links && pnpm --filter @reserve-protocol/dtf-catalog pack --dry-run && pnpm --filter @reserve-protocol/sdk pack --dry-run && pnpm --filter @reserve-protocol/react-sdk pack --dry-run", + "release:ci": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm graphql:codegen:check && pnpm test && pnpm build && pnpm --filter @reserve-protocol/docs check:sdk-bundle && pnpm docs:links && pnpm --filter @reserve-protocol/dtf-catalog check:restricted && pnpm --filter @reserve-protocol/dtf-catalog check:tokenlists && pnpm --filter @reserve-protocol/dtf-catalog pack --dry-run && pnpm --filter @reserve-protocol/sdk pack --dry-run && pnpm --filter @reserve-protocol/react-sdk pack --dry-run", "test": "turbo run test", "typecheck": "turbo run typecheck" }, From 3b0fbfbc7ae9e845ae54a298506b98fa05a42026 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 23 Jul 2026 08:05:58 -0700 Subject: [PATCH 18/19] docs: sync SDK docs and wiki with the 0.5.0 surface --- README.md | 2 +- apps/docs/src/pages/protocol/data-sources.mdx | 3 +- .../src/pages/react-sdk/index-dtf-hooks.mdx | 82 ++++++++++--------- apps/docs/src/pages/sdk/index-dtf.mdx | 39 +++++---- docs/index-dtf/discovery-holders.md | 7 +- docs/index-dtf/issuance-redemption.md | 2 +- .../register-index-dtf-react-sdk-migration.md | 6 +- docs/protocol/data-sources.md | 3 +- docs/register/interface.md | 3 +- docs/wiki/decisions.md | 4 +- docs/wiki/domains/react-sdk.md | 6 +- docs/wiki/domains/sdk.md | 7 +- docs/wiki/log.md | 6 +- docs/wiki/progress.md | 16 ++-- docs/wiki/project.md | 6 +- 15 files changed, 108 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 235b1b4..7e9c9ef 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ pnpm docs:links pnpm release:ci ``` -`pnpm release:ci` runs typecheck, tests, builds (including the docs site), local documentation-link checks, and package dry-runs for the publishable packages. +`pnpm release:ci` runs typecheck, lint/format, live-schema GraphQL codegen freshness, tests, builds (including the docs site and SDK bundle budget), documentation/catalog checks, and package dry-runs for all publishable packages. ## Quick Example diff --git a/apps/docs/src/pages/protocol/data-sources.mdx b/apps/docs/src/pages/protocol/data-sources.mdx index f11d66e..5b0b666 100644 --- a/apps/docs/src/pages/protocol/data-sources.mdx +++ b/apps/docs/src/pages/protocol/data-sources.mdx @@ -11,7 +11,8 @@ Reserve integrations should use the right source for each kind of data. | Active auction | RPC | Must be real-time for bidding. | | Proposal state | RPC plus subgraph | Subgraph indexes events; RPC owns live terminal state. | | Prices | Reserve API | Aggregated current and historical price data. | -| Discovery | Reserve API or catalog | Product listing and curated status. | +| Discovery and bulk status | Reserve API | Current product list and batch status rows. | +| Single-DTF status | `@reserve-protocol/dtf-catalog` | Synchronous curated route status; unknown is active. | | Governance metadata | Index subgraph | Historical proposal/config metadata. | | Holders and transactions | Index subgraph/API-backed surfaces | Indexed history. | | Vote-lock DAO stats | Reserve API | Aggregated DAO/vote-lock position data. | 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 ad64ffa..09d92cb 100644 --- a/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx +++ b/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx @@ -20,17 +20,18 @@ const dtfs = useDiscoverDtfs({ ## DTF Detail -| Hook | Core SDK method | -| ------------------------- | --------------------------- | -| `useIndexDtf` | `sdk.index.get` | -| `useIndexDtfBasket` | `sdk.index.getBasket` | -| `useIndexDtfVersion` | `sdk.index.getVersion` | -| `useIndexDtfBrand` | `sdk.index.getBrand` | -| `useIndexDtfMandate` | `sdk.index.getMandate` | -| `useIndexDtfTotalSupply` | `sdk.index.getTotalSupply` | -| `useIndexDtfTotalAssets` | `sdk.index.getTotalAssets` | -| `useIndexDtfPrice` | `sdk.index.getPrice` | -| `useIndexDtfPriceHistory` | `sdk.index.getPriceHistory` | +| Hook | Core SDK method | +| ------------------------- | ------------------------------------ | +| `useIndexDtf` | `sdk.index.get` | +| `useIndexDtfBasket` | `sdk.index.getBasket` | +| `useIndexDtfVersion` | `sdk.index.getVersion` | +| `useIndexDtfBrand` | `sdk.index.getBrand` | +| `useIndexDtfMandate` | `sdk.index.getMandate` | +| `useIndexDtfTotalSupply` | `sdk.index.getTotalSupply` | +| `useIndexDtfTotalAssets` | `sdk.index.getTotalAssets` | +| `useIndexDtfPrice` | `sdk.index.getPrice` | +| `useIndexDtfPriceHistory` | `sdk.index.getPriceHistory` | +| `useIndexDtfPerformance` | composed `sdk.index.getPriceHistory` | ```tsx const basket = useIndexDtfBasket({ address, chainId }); @@ -39,34 +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` | -| `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` | +| `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. ```tsx const issuance = useIndexDtfIssuanceState( diff --git a/apps/docs/src/pages/sdk/index-dtf.mdx b/apps/docs/src/pages/sdk/index-dtf.mdx index 5d4f633..ebc5238 100644 --- a/apps/docs/src/pages/sdk/index-dtf.mdx +++ b/apps/docs/src/pages/sdk/index-dtf.mdx @@ -17,7 +17,7 @@ const dtf = sdk.index.ref({ address, chainId }); | `sdk.index.list(params?)` | `@reserve-protocol/dtf-catalog` | Curated static catalog | | `sdk.index.getCatalogEntries(params?)` | catalog | Raw catalog entries | | `sdk.index.resolveAlias(params)` | catalog | Resolve ticker/name/address-like aliases | -| `sdk.index.getStatus(params)` | Reserve API discovery | Product listing status | +| `sdk.index.getStatus(params)` | `@reserve-protocol/dtf-catalog` | Synchronous route/product status | | `sdk.index.getStatuses(params?)` | Reserve API discovery | Bulk status map | ```ts @@ -30,23 +30,26 @@ const dtfs = await sdk.index.discover({ ## DTF Reads -| Method | Use | -| ------------------- | ----------------------------------------------------------------------- | -| `get` | Full product-shaped view with market, basket, platform fee, and status. | -| `getDtf` | Subgraph DTF metadata. | -| `getBasket` | Live RPC basket from `totalAssets()`. | -| `getBasketSnapshot` | Reserve API current or block snapshot basket. | -| `getVersion` | DTF version from contract. | -| `getTotalSupply` | Live total supply. Includes pending fee shares. | -| `getTotalAssets` | Live total assets. | -| `getBrand` | Product brand metadata. | -| `getMandate` | DTF mandate string. | -| `getPrice` | Current Reserve API price. | -| `getPrices` | Batch current prices. | -| `getPriceHistory` | Historical Reserve API price points. | -| `getExposure` | Exposure/analytics data. | -| `getHolders` | Holder data. | -| `getTransactions` | Transaction history. | +| Method | Use | +| --------------------------- | ----------------------------------------------------------------------- | +| `get` | Full product-shaped view with market, basket, platform fee, and status. | +| `getDtf` | Subgraph DTF metadata. | +| `getBasket` | Live RPC basket from `totalAssets()`. | +| `getBasketSnapshot` | Reserve API current or block snapshot basket. | +| `getVersion` | DTF version from contract. | +| `getTotalSupply` | Live total supply. Includes pending fee shares. | +| `getTotalAssets` | Live total assets. | +| `getBrand` | Product brand metadata. | +| `getMandate` | DTF mandate string. | +| `getPrice` | Current Reserve API price. | +| `getPrices` | Batch current prices. | +| `getPriceHistory` | Historical Reserve API price points. | +| `getExposure` | Exposure/analytics data. | +| `getHolders` | Holder data. | +| `getAccountBalanceSnapshot` | Latest indexed account balance at or before a mark. | +| `getTransactions` | Transaction history. | + +`getStatus` validates its address, reads the chain-scoped catalog, and treats DTFs absent from the catalog as active. `getStatuses` remains an asynchronous Reserve API discovery projection for bulk/list screens. `getBrand` preserves overview media metadata, including `video` and downloadable `files`, in addition to covers, descriptions, profiles, tags, and social links. diff --git a/docs/index-dtf/discovery-holders.md b/docs/index-dtf/discovery-holders.md index eed9726..92f9467 100644 --- a/docs/index-dtf/discovery-holders.md +++ b/docs/index-dtf/discovery-holders.md @@ -30,7 +30,12 @@ Best-effort behavior should be explicit: ## Status -Product status values include active/deprecated/unsupported depending on API data. +Product status values are active/deprecated/unsupported. The two public reads serve different shapes: + +- `getStatus({ address, chainId })` is a synchronous, chain-scoped catalog lookup. It validates the address and treats a DTF absent from the catalog as active. +- `getStatuses(params?)` projects statuses from asynchronous Reserve API aggregate discovery for bulk/list screens. + +The catalog and API should agree for curated entries, but consumers must choose by use case rather than treating the two methods as aliases. Register behavior: diff --git a/docs/index-dtf/issuance-redemption.md b/docs/index-dtf/issuance-redemption.md index 942a5b7..b0cd8b7 100644 --- a/docs/index-dtf/issuance-redemption.md +++ b/docs/index-dtf/issuance-redemption.md @@ -90,7 +90,7 @@ The SDK should prepare approval calls, not send them. | User balances | RPC ERC20 `balanceOf` | | Allowances | RPC ERC20 `allowance` | | Current prices/display | Reserve API | -| Deprecated/sell-only status | Reserve API discovery/status | +| Deprecated/sell-only status | SDK catalog-backed single-DTF status | ## SDK Scope diff --git a/docs/plans/register-index-dtf-react-sdk-migration.md b/docs/plans/register-index-dtf-react-sdk-migration.md index 2a6d42c..1197eae 100644 --- a/docs/plans/register-index-dtf-react-sdk-migration.md +++ b/docs/plans/register-index-dtf-react-sdk-migration.md @@ -88,7 +88,7 @@ The remaining work is Register adoption and new consumption-driven reads, not an | Version | `useIndexDtfVersion` | Keep | Already migrated. | | Platform fee | wagmi `daoFeeRegistry()` and `getFeeDetails()` | add `platformFee` to `sdk.index.get` / `useCurrentIndexDtf` and read it from the route model | Keep `useIndexDtfPlatformFee` for focused screens that do not need the full DTF. Preserve Register fallback behavior only if product still requires it. | | Exposure | direct `fetch(${RESERVE_API}dtf/exposure...)` | `useIndexDtfExposure` | Keep `refetchInterval: 60000` in Register if product needs it. | -| Deprecation status | `useDTFStatus`, direct `/discover/dtfs`, `KNOWN_DEPRECATED` snapshot | add `status` to `sdk.index.get` / `useCurrentIndexDtf`, backed by shared/batched discovery | Do not replace this with one `useIndexDtfStatus` per route: the current core method scans discovery pages. Keep the focused hook for standalone consumers and preserve `KNOWN_DEPRECATED` until the aggregate has equivalent instant fail-safe behavior. | +| Deprecation status | `useDTFStatus`, direct `/discover/dtfs`, `KNOWN_DEPRECATED` snapshot | `status` on `sdk.index.get` / `useCurrentIndexDtf`, backed by the synchronous catalog lookup | Use `useIndexDtfStatus` only when a focused synchronous status read is needed. Bulk discovery/list status remains Reserve API-backed through `getStatuses`; preserve `KNOWN_DEPRECATED` only if product still requires a pre-provider fail-safe. | | Discovery/list page | `useIndexDTFList`, direct `/discover/dtfs?performance=true&brand=true` | No drop-in React SDK hook today | Add a React hook around `sdk.index.discover({ performance: true, brand: true })` only after core SDK can preserve Register's strict `type === 'index'` behavior or expose enough raw type data to filter. `useIndexDtfList` is catalog/static, not the discovery-card data source. Preserve the exported `IndexDTFItem` shape or migrate every consumer together. Until strict filtering is possible, keep Register's direct fetch. | | Transactions table | `useIndexDTFTransactions`, direct subgraph/API mix | `useIndexDtfTransactions` | Preserve `dtfPriceUsd` behavior if the table needs USD value at event time. | | Price chart | Reserve API historical chart hooks | `useIndexDtfPriceHistory` where source matches, wrapped by Register adapters | Keep BTC-specific Binance comparison hooks product-owned. Preserve Register's live appended datapoint, prefetching, loading behavior, and current response shape where needed. | @@ -118,7 +118,7 @@ The remaining work is Register adoption and new consumption-driven reads, not an ## React SDK Hook Status -Shipped: platform fee, rebalance control, bids enabled, pending fee shares, approved revenue tokens, holders, total supply/assets, portfolio history/transactions, rebalance detail, active/latest auction, bid quote, and rebalance-liquidity query options/hooks. The full/current DTF result now includes platform fee and status. +Shipped: platform fee, rebalance control, bids enabled, pending fee shares, approved revenue tokens, holders, account balance snapshots, total supply/assets, price-history prefetch/performance composition, portfolio history/transactions, rebalance detail, active/latest auction, bid quote, and rebalance-liquidity query options/hooks. The full/current DTF result includes platform fee and synchronous catalog-backed status. Remaining additions must have a concrete Register replacement: @@ -212,7 +212,7 @@ Work in `dtf-sdk`, then Register after verifying response parity: - Do not use `useIndexDtfList` as the discovery-card replacement. It is catalog/list data, not the current Reserve API discovery shape. - Replace `useIndexDTFList` only after preserving the `IndexDTFItem` shape or migrating all consumers together. - Preserve Register's strict `type === 'index'` filter unless product accepts SDK behavior that includes missing `type` rows. Current mapped SDK discovery items drop `type`, so this may require a core SDK change before migration. -- Put route status on `useCurrentIndexDtf`; use discovery/batch status for lists. Do not issue one paginated `useIndexDtfStatus` query per row or route. +- Put route status on `useCurrentIndexDtf`; use discovery/batch status for lists. `useIndexDtfStatus` is a synchronous catalog lookup, not a paginated query. - Keep the generic `useDTFStatus` or split it for Yield DTF and Earn consumers. - Keep `KNOWN_DEPRECATED` if instant pre-load status is still needed. diff --git a/docs/protocol/data-sources.md b/docs/protocol/data-sources.md index 355a6f5..c13d374 100644 --- a/docs/protocol/data-sources.md +++ b/docs/protocol/data-sources.md @@ -9,7 +9,8 @@ Reserve integrations should route reads by ownership, not convenience. A compose | Live basket assets and balances | RPC `totalAssets()` | Basket is live onchain state | | Live share supply | RPC `totalSupply()` | Includes pending fee shares | | Current price and basket valuation | Reserve API `/current/dtf`, `/current/dtfs` | API aggregates prices and weights | -| Discovery/status/listing | Reserve API `/discover/dtf`, `/discover/dtfs` | Product listing and status are curated there | +| Discovery/listing and bulk status | Reserve API `/discover/dtf`, `/discover/dtfs` | API owns current list rows and batch status | +| Single-DTF route status | `@reserve-protocol/dtf-catalog` | Synchronous curated status; absent entries are active | | Historical price/performance | Reserve API historical endpoints | API owns analytics windows | | Governance metadata | Index subgraph | Historical proposals, governances, roles | | Live proposal state | RPC governor reads | Subgraph state can lag or be event-only | diff --git a/docs/register/interface.md b/docs/register/interface.md index c263ffa..e785aac 100644 --- a/docs/register/interface.md +++ b/docs/register/interface.md @@ -57,7 +57,8 @@ Deprecated/unsupported DTFs disable auction surfaces. Factsheet/performance hide Register container/page data uses: - Index subgraph for DTF metadata, governance, roles, vote-lock, fee recipient history. -- Reserve API for current basket/price, brand, discovery/status, exposure, historical charts, vote-lock positions. +- Reserve API for current basket/price, brand, discovery/bulk status, exposure, historical charts, vote-lock positions. +- `@reserve-protocol/dtf-catalog` through the SDK for synchronous single-route status. - RPC for live `totalAssets`, `totalSupply`, `rebalanceControl`, platform fee registry, staking vault state, proposal/gov actions. See `protocol/data-sources.md` for source ownership. diff --git a/docs/wiki/decisions.md b/docs/wiki/decisions.md index d9ab271..905687f 100644 --- a/docs/wiki/decisions.md +++ b/docs/wiki/decisions.md @@ -1,6 +1,6 @@ --- title: Decisions -updated: 2026-07-09 +updated: 2026-07-22 type: decision --- @@ -26,7 +26,7 @@ A consumer module trace showed that a direct price read retains unrelated Zod sc ## 2026-07-09 — Current DTF is the route aggregate -Platform fee and status belong on the full/current DTF route model so Register does not mount one updater per field. Focused hooks remain for consumers that only need one value. Status must reuse batch/discovery data rather than scan paginated discovery per DTF. Rebalance health calculations and the Reserve API liquidity boundary belong in the SDK; interaction timing, retry UI, warnings, and Zapper support checks stay in Register. +Platform fee and status belong on the full/current DTF route model so Register does not mount one updater per field. Focused hooks remain for consumers that only need one value. Single-DTF status is a synchronous chain-scoped `@reserve-protocol/dtf-catalog` lookup with absent entries treated as active; bulk/list status remains a Reserve API discovery projection. Rebalance health calculations and the Reserve API liquidity boundary belong in the SDK; interaction timing, retry UI, warnings, and Zapper support checks stay in Register. ## 2026-07-09 — Preserve modules, keep the root API diff --git a/docs/wiki/domains/react-sdk.md b/docs/wiki/domains/react-sdk.md index 29ae6a6..50b21ac 100644 --- a/docs/wiki/domains/react-sdk.md +++ b/docs/wiki/domains/react-sdk.md @@ -1,6 +1,6 @@ --- title: React SDK Domain -updated: 2026-07-09 +updated: 2026-07-22 type: domain sources: - packages/react-sdk/src/** @@ -15,9 +15,11 @@ sources: ## Shape - `DtfSdkProvider` owns one configured core SDK; `IndexDtfProvider` optionally binds route identity. -- Query options are the reusable primitive. Hooks call `useQuery()` directly and preserve `select` and caller overrides. +- Query options are the reusable primitive. Query-backed hooks call `useQuery()` directly and preserve `select` and caller overrides. - Query keys include normalized identity and parameters. Bigints and addresses must remain stable across equivalent calls. - Freshness is explicit: live 10s, ordinary 30s, static 5m unless the source needs a documented exception. +- `useIndexDtfStatus` is the deliberate exception: a synchronous catalog lookup with no query key or options. +- `useIndexDtfPerformance` owns `select` so the canonical history cache stays raw; explicit zero price/supply values still append a live point. Account snapshots and price-history prefetch use the same canonical keys as their ordinary read hooks. ## Performance diff --git a/docs/wiki/domains/sdk.md b/docs/wiki/domains/sdk.md index 0503827..1d258c5 100644 --- a/docs/wiki/domains/sdk.md +++ b/docs/wiki/domains/sdk.md @@ -1,6 +1,6 @@ --- title: Core SDK Domain -updated: 2026-07-14 +updated: 2026-07-22 type: domain sources: - packages/sdk/src/** @@ -17,6 +17,7 @@ sources: - `createDtfSdk()` composes flat Index, Yield, and portfolio namespaces. - DTF refs bind stable address/chain identity and expose the same product operations without a generic binder. - RPC, subgraph, Reserve API, explorer, and catalog boundaries stay visible in their domain modules. +- Single-DTF status is a synchronous, validated catalog lookup; bulk `getStatuses` stays Reserve API-backed for list screens. They are related product views, not aliases. - Mappers convert raw source shapes only. Business state, time, and network calls remain outside mappers. ## Invariants @@ -25,7 +26,9 @@ sources: - Proposal vote success is OZ strict majority: a for/against tie is DEFEATED. Subgraph proposal state lags time-based transitions, so list/detail state surfaces derive from votes, quorum, and deadline instead of returning the raw field. - Index proposal IDs are globally unique and do not need DTF-membership checks. - Public call builders require exact calldata and value assertions when changed. -- GraphQL document changes must regenerate and diff committed generated outputs. +- GraphQL generated output must match the configured deployed schemas; ordinary CI and `release:ci` rerun codegen and reject drift. +- Account balance snapshots bind through both the namespace and DTF ref. `selectPriceAtMark(points, mark?)` requires timestamped points, never selects a future or non-positive price when a mark is provided, and preserves latest-positive selection when it is omitted. +- Yield proposal lists combine indexed vote totals with the latest chain-native timepoint, so they can be eventually consistent near `voteEnd`; proposal detail reads authoritative governor state. ## Current pressure diff --git a/docs/wiki/log.md b/docs/wiki/log.md index af9c07e..2ea5d3b 100644 --- a/docs/wiki/log.md +++ b/docs/wiki/log.md @@ -1,6 +1,6 @@ --- title: Log -updated: 2026-07-14 +updated: 2026-07-22 type: log --- @@ -23,3 +23,7 @@ Append-only chronological record: lessons, corrections, friction. Newest section - Governance hardening (Register audit Z18/Z22 follow-through): Index `getProposalState` treated a for/against tie as SUCCEEDED; both FolioGovernor (OZ 5.1.0 GovernorCountingSimpleUpgradeable) and Yield Governance.sol (vendored GovernorCountingSimple) require forVotes strictly over againstVotes, so ties now resolve DEFEATED. Yield proposal lists previously returned raw subgraph state, which lags time-based transitions; `getYieldDtfProposalState` now derives PENDING/ACTIVE resolution summary-level (bigint votes, quorum, native timepoint), fetching the block number only when a non-terminal Alexios proposal needs it. Lesson: subgraph state is event-driven everywhere — any list surface showing proposal state needs a derivation, not the raw field. - The open-auction builder tests fully mocked dtf-rebalance-lib, so nothing proved its zero-price/zero-supply guards surfaced through `prepareIndexDtfOpenAuctionArgs`. An unmocked integration spec now pins those throws and one golden exact-calldata fixture for a fixed two-token rebalance snapshot. Lib errors intentionally pass through raw (no SdkError wrap): messages like "auction launcher MUST closeRebalance" carry operator instructions that must not be reshaped. + +## 2026-07-22 + +- Multi-repo SDK/Register work exposed avoidable approval churn when only Register was writable. Start those sessions with both repositories as writable workspace roots (or their parent as the workspace); sibling read-only inspection does not need escalation, and write-heavy SDK verification should be batched into the release gate. diff --git a/docs/wiki/progress.md b/docs/wiki/progress.md index b3ba4ed..b49a181 100644 --- a/docs/wiki/progress.md +++ b/docs/wiki/progress.md @@ -1,6 +1,6 @@ --- title: Progress -updated: 2026-07-14 +updated: 2026-07-22 type: ledger --- @@ -8,17 +8,17 @@ 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 | -| ----------------------------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| 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 | +| ----------------------------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| 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 -- `getYieldDtfProposalState` labels zero-engagement PENDING proposals past `voteEnd` as EXPIRED, matching Register's `getProposalStatus` and the Index derivation; strict OZ semantics would compute the vote outcome instead. Product decision whether to change all three surfaces together. - Yield detail (`getYieldDtfProposal`) trusts on-chain `governor.state()`, which reports failed-quorum proposals as DEFEATED while the list derivation distinguishes QUORUM_NOT_REACHED. Decide whether detail should layer the display distinction. - `getYieldDtfProposalState(proposal, currentTimepoint)` relies on the caller passing the governor-native unit (seconds vs blocks); consider an explicit `isTimepointBased` param like `getYieldDtfProposalVotePower` if external adoption grows. diff --git a/docs/wiki/project.md b/docs/wiki/project.md index e512a82..717862b 100644 --- a/docs/wiki/project.md +++ b/docs/wiki/project.md @@ -1,6 +1,6 @@ --- title: Project -updated: 2026-07-09 +updated: 2026-07-22 type: context --- @@ -15,8 +15,8 @@ Start with `docs/README.md`, then `docs/sdk/architecture.md` and `docs/sdk/api-s ## Stack Specifics - pnpm/Turbo monorepo on Node 24; TypeScript 6, viem, Zod, GraphQL Codegen, Vitest, React 18/19, TanStack Query 5, tsdown, and Vocs. -- Normal closeout commands are owned by `llm-workflow.config.json`. `pnpm release:ci` is the publication check and additionally dry-runs package tarballs. -- Generated GraphQL documents are committed. Run `pnpm graphql:codegen` after GraphQL document or codegen changes. +- Normal closeout commands are owned by `llm-workflow.config.json`. `pnpm release:ci` is the publication check: it adds live-schema GraphQL freshness and package tarball dry-runs to the compile, test, docs, bundle, and catalog gates. +- Generated GraphQL documents are committed. Run `pnpm graphql:codegen` after GraphQL document or codegen changes; ordinary CI and the publication gate reject stale generated output against the configured deployed schemas. - Releases use Changesets. Never edit package versions or changelogs by hand. ## Safety Rules From bccf54ef685384b9d7eb070a1d62fb153f6cfd58 Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 23 Jul 2026 08:17:56 -0700 Subject: [PATCH 19/19] docs: scope the select-option claim and the proposal-state bullets (coderabbit) --- apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx | 4 +++- docs/wiki/domains/sdk.md | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) 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 09d92cb..565ca95 100644 --- a/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx +++ b/apps/docs/src/pages/react-sdk/index-dtf-hooks.mdx @@ -100,7 +100,9 @@ Use them for previewing proposal payloads before submit. ## Select Option -All hooks support `select`. +Ordinary query hooks support `select`. Two exceptions: `useIndexDtfStatus` is a +synchronous catalog lookup (no query options), and `useIndexDtfPerformance` +reserves `select` for its own live-point composition. ```tsx const symbol = useIndexDtf( diff --git a/docs/wiki/domains/sdk.md b/docs/wiki/domains/sdk.md index 1d258c5..44cf86f 100644 --- a/docs/wiki/domains/sdk.md +++ b/docs/wiki/domains/sdk.md @@ -23,10 +23,10 @@ sources: ## Invariants - On-chain integer amounts are `Amount`; display-class values may be numbers. -- Proposal vote success is OZ strict majority: a for/against tie is DEFEATED. Subgraph proposal state lags time-based transitions, so list/detail state surfaces derive from votes, quorum, and deadline instead of returning the raw field. -- Index proposal IDs are globally unique and do not need DTF-membership checks. +- Proposal vote success is OZ strict majority for both products: a for/against tie is DEFEATED. Subgraph proposal state lags time-based transitions, so proposal-state surfaces derive from votes, quorum, and deadline instead of returning the raw field — except Yield proposal detail, which reads authoritative governor state (last bullet). +- Index DTF proposal IDs are globally unique and do not need DTF-membership checks. - Public call builders require exact calldata and value assertions when changed. -- GraphQL generated output must match the configured deployed schemas; ordinary CI and `release:ci` rerun codegen and reject drift. +- GraphQL-generated output must match the configured deployed schemas; ordinary CI and `release:ci` rerun codegen and reject drift. - Account balance snapshots bind through both the namespace and DTF ref. `selectPriceAtMark(points, mark?)` requires timestamped points, never selects a future or non-positive price when a mark is provided, and preserves latest-positive selection when it is omitted. - Yield proposal lists combine indexed vote totals with the latest chain-native timepoint, so they can be eventually consistent near `voteEnd`; proposal detail reads authoritative governor state.