diff --git a/docs/wiki/domains/tooling.md b/docs/wiki/domains/tooling.md new file mode 100644 index 0000000..6918f22 --- /dev/null +++ b/docs/wiki/domains/tooling.md @@ -0,0 +1,44 @@ +--- +title: Tooling Domain +updated: 2026-08-03 +type: domain +sources: + - packages/tooling/** +--- + +# Tooling Domain + +## Boundary + +`@reserve-protocol/tooling` is the private workspace home for operational scripts and +agent skills that review protocol activity. It consumes `@reserve-protocol/sdk` and +`@reserve-protocol/dtf-catalog`; nothing depends on it, and it publishes nothing. Logic +that other surfaces would need belongs in the SDK, not here. + +## Shape + +- One folder per workflow under `src/` (`rebalance-validation/`), with its checks in + `checks/` and its outside data providers in `sources/`. +- Each workflow has a CLI entry (`cli.ts`) run through `tsx`, and a skill in + `skills//SKILL.md` that says how to act on the output. +- Protocol reads, decoding and math go through the SDK. The package holds only the + review logic and the sources the SDK deliberately does not own. + +## Invariants + +- Rebalance review is two passes: `disasters` gates (nonzero exit), `outcomes` informs. + Only disaster-pass failures can block a proposal. +- Disaster checks use data from outside Reserve (pool prices, third-party token + listings). The proposal is built from the Reserve API, so an API-vs-calldata + comparison cannot detect a wrong API price. +- Calldata correctness is established by re-deriving it through + `buildIndexDtfStartRebalanceArgs`, not by re-implementing the weight math. +- Every run prints the questions that cannot be checked mechanically; a clean report + is not a completed review. + +## Encoding + +`weight.spot` is `D27{tok/share}` (per share, so whole tokens per whole share is +`spot / 1e27 * 1e18 / 10**decimals`); `price` is `D27{nanoUSD/tok}` with +`low = p*(1-e)` / `high = p/(1-e)`, so price is `sqrt(low*high)`; `maxAuctionSize` is +`{tok}`, not USD. diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 5e8ce82..ed72ad1 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -17,3 +17,4 @@ One line per page. Agents: start here, follow links, keep this list current on i - [[sdk]] — core reads, mapping, namespaces/refs, and prepared-call boundary - [[react-sdk]] — providers, query keys/options/hooks, and performance rules +- [[tooling]] — operational scripts and agent skills for protocol review diff --git a/docs/wiki/log.md b/docs/wiki/log.md index 2ea5d3b..e33f876 100644 --- a/docs/wiki/log.md +++ b/docs/wiki/log.md @@ -27,3 +27,8 @@ Append-only chronological record: lessons, corrections, friction. Newest section ## 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. + +## 2026-08-03 + +- DTF rebalance validation moved into `packages/tooling` (an earlier prototype lived in Register and was dropped as out of scope there). Two lessons from running it against the live CMC20 August 2026 proposal: `weight.spot` is D27 per _share_, not per whole share, so trade sizing that treats it as whole-token units per share reports the entire basket as a sell; and `POST /rebalance/liquidity` returns `priceImpact` already in percent and signed by direction, so treating it as a fraction reports 11.6% impact as 1152%. Both were invisible to unit tests and only surfaced against real data — validate new checks against a known proposal, not just fixtures. +- Disaster-class checks in that package deliberately use non-Reserve data (DEXScreener pool prices, CoinGecko listings). Proposals are built from the Reserve API, so an API-vs-calldata comparison is self-consistent by construction and cannot catch a wrong price or a look-alike token address. diff --git a/docs/wiki/progress.md b/docs/wiki/progress.md index 0ce0885..a69158c 100644 --- a/docs/wiki/progress.md +++ b/docs/wiki/progress.md @@ -10,6 +10,7 @@ Stage ledger. One row per stage; keep entries short. Verifier = exact fresh comm | Stage | Status | Verifier | Review | Next | | ----------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DTF rebalance validation tooling (packages/tooling) | done (base origin/main) | scoped verify green: format:check + lint + docs:links + typecheck + `turbo run test --force` (tooling 10); CLI run on the live CMC20 proposal | correctness+complexity: self-review; fixed per-share unit scaling and percent-unit price impact, both caught against live data | trading desk answers the $10k/5%/$50k flags; decide the `ttl > launcher window` policy | | vote-lock self-appreciating vault support (0.5.1) | human-review-required (base 9231139) | scoped verify green: format/lint/typecheck/test both packages (vote-lock.test 2, query-keys 12) + bundle + docs:links + wiki-lint | Dark + Light; blocker fixed (redeem builder missing from index-dtf/index.ts + index.ts barrels); multicall-order assertion added; patch changeset per Luis (0.5.1, though API additions are minor-shaped) | **Engineer review required** (redeem calldata builder + VoteLockState staking surface); then changesets release 0.5.1; register consumes via local link until published | | 0.5.0 hardening release closeout | done (base f1ee8f5) | Node 24 `release:ci`: types + lint/format + live codegen + 394 tests (+17 live skipped) + build/bundle + 93 docs + catalog + 3 LICENSE-bearing tarballs; forced closeout gate + wiki-lint green | correctness+security+product+complexity: PR #27 reconciled; zero values, status validation, timestamp selection, namespace/ref/hook coverage, schema drift, publish gate, docs, and linked Register RED→GREEN verified | engineer review; commit/push; Changesets release to 0.5.0 | | governance tie semantics + yield list state + rebalance hardening tests | done (base 588954e) | full gate on Node 24: forced builds + sdk-bundle + typecheck + lint + format + forced tests (sdk 287 passed/17 live skipped, react-sdk 72) + docs links + catalog checks; wiki-lint green | correctness+security+product+complexity: Dark+Light subagent pair; adopted boundary/mixed-flavor vectors and pinned zero-supply message; PENDING-expired labeling and detail QUORUM_NOT_REACHED split verified against Register reference, sent to backlog | Luis review (user-visible governance badge change); release patch | diff --git a/packages/tooling/README.md b/packages/tooling/README.md new file mode 100644 index 0000000..23095bd --- /dev/null +++ b/packages/tooling/README.md @@ -0,0 +1,47 @@ +# @reserve-protocol/tooling + +Operational tooling for reviewing protocol activity, built on `@reserve-protocol/sdk` +and `@reserve-protocol/dtf-catalog`. Private to the workspace — scripts and agent +skills, not a published surface. + +## Rebalance validation + +```bash +pnpm --filter @reserve-protocol/tooling validate:rebalance \ + "https://app.reserve.org/bsc/index-dtf/cmc20/governance/proposal/" +``` + +Exits 1 when a disaster check fails, 0 otherwise. The agent-facing procedure — +how to triage the output and who owns each warning — is +[`skills/validating-dtf-rebalances/SKILL.md`](./skills/validating-dtf-rebalances/SKILL.md). + +### What it checks + +Pass one, "preventing disasters" (blocking): + +| Check | Catches | +| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| single `startRebalance` on the DTF, routed through the governor whose timelock holds `REBALANCE_MANAGER` | a proposal that passes and then reverts, or a rebalance smuggled in with other actions | +| calldata re-derived through `@reserve-protocol/dtf-rebalance-lib` (via the SDK) | hand-edited weights, price ranges or rebalance limits | +| every held asset present in the calldata; additions and zero-weight exits surfaced | assets stranded outside the rebalance | +| encoded price vs the deepest-pool price, per asset | wrong decimals, a stale or wrong API price, the wrong token | +| proposed basket shares re-valued at pool prices | value shifted into the wrong place while each price still looks plausible | +| per-share units vs the last executed rebalance | order-of-magnitude weight jumps | +| basket addresses against a third-party address→coin map | look-alike and scam addresses | + +Pass two, "optimizing outcomes" (informational): auction launcher window vs TTL +and the resulting permissionless tail, turnover as a share of AUM, legs above +$10,000, constituents with less than $50,000 of pooled liquidity, and price +impact per leg from the production `POST /rebalance/liquidity` route. + +Prices and token identity in pass one come from sources outside Reserve +(DEXScreener, CoinGecko) on purpose: the proposal was built from the Reserve API, +so only an independent mark can catch that API being wrong. + +### Encoding notes + +`weight.spot` is `D27{tok/share}` — per _share_, so whole tokens per whole share is +`spot / 1e27 * 1e18 / 10**decimals`. `price` is `D27{nanoUSD/tok}` with +`low = p*(1-e)`, `high = p/(1-e)`, so the asset price is `sqrt(low*high)` and the +price-error preset is `1 - low/price`. `maxAuctionSize` is encoded in `{tok}`, not +USD. `limits.high` is `1/(1-basketError)` for TRACKING DTFs and `1e18` for NATIVE. diff --git a/packages/tooling/package.json b/packages/tooling/package.json new file mode 100644 index 0000000..50cae42 --- /dev/null +++ b/packages/tooling/package.json @@ -0,0 +1,33 @@ +{ + "name": "@reserve-protocol/tooling", + "version": "0.0.0", + "private": true, + "description": "Operational tooling and agent skills for DTF review workflows.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/reserve-protocol/dtf-interface", + "directory": "packages/tooling" + }, + "type": "module", + "scripts": { + "clean": "rm -rf *.tsbuildinfo", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json --noEmit", + "validate:rebalance": "tsx src/rebalance-validation/cli.ts" + }, + "dependencies": { + "@reserve-protocol/sdk": "workspace:*", + "viem": "catalog:" + }, + "devDependencies": { + "@dtf-interface/tsconfig": "workspace:*", + "@types/node": "catalog:", + "tsx": "^4.20.6", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=24" + } +} diff --git a/packages/tooling/skills/validating-dtf-rebalances/SKILL.md b/packages/tooling/skills/validating-dtf-rebalances/SKILL.md new file mode 100644 index 0000000..b6afc12 --- /dev/null +++ b/packages/tooling/skills/validating-dtf-rebalances/SKILL.md @@ -0,0 +1,68 @@ +--- +name: validating-dtf-rebalances +description: Use when asked to review, validate, or sanity-check an Index DTF rebalance governance proposal (an app.reserve.org `.../governance/proposal/` link), before voting on or executing one. +--- + +# Validating DTF rebalances + +A rebalance proposal encodes weights, price ranges and auction timing as calldata. +Two kinds of thing go wrong, and they are not equally bad: + +- **Disasters** — the calldata moves real value into the wrong place: a mispriced + asset, wrong decimals, a scam or look-alike token address, the wrong governor. + These block the proposal. +- **Execution** — the trade is correct but fills badly: thin liquidity, high price + impact, an auction nobody opens. These need preparation, not a veto. + +Run the disaster pass first and do not weigh execution findings against it. + +## Steps + +1. Run the checker on the proposal URL: + + ```bash + pnpm --filter @reserve-protocol/tooling validate:rebalance "" + ``` + + Done when it prints a verdict line. Exit code 1 means a disaster check failed; + 0 with warnings means execution risks only. + +2. Resolve every `FAIL`. A failure is a claim about the calldata, so answer it + with the calldata: read the check's detail line, then confirm against an + explorer or an independent price source. Done when each failure is either a + fixed proposal or a written explanation of why the check is wrong here. + +3. Take each `WARN` to the person who owns it. Done when each warning has a + named owner and an answer: + - trade above $10,000, price impact above 5%, or liquidity below $50,000 → + the trading desk, who decides whether to buy inventory before the auction. + - basket addition or removal → whoever owns the index mandate. + - permissionless tail (`ttl` beyond the launcher window) → the auction + launcher operator. + +4. Answer the "still needs a human" questions the run prints. They cannot be + checked mechanically (is the constituent universe official? is this wrapper + canonical?), and a clean report without them is not a review. Done when each + is answered or explicitly deferred to a named person. + +5. Report the verdict as: disaster pass result, then execution flags with owners, + then unanswered questions. Never report "looks good" while a question from + step 4 is open. + +## Adding a check + +Add a check when a real proposal could go wrong in a way the current run would +miss, not to restate something already covered. + +- Independent data only for disaster checks. The proposal was built from the + Reserve API, so an API-vs-calldata comparison cannot detect a wrong API price; + pool prices and third-party token listings can. New disaster checks belong in + `src/rebalance-validation/checks/`, new outside sources in + `src/rebalance-validation/sources/`. +- A check needs a threshold that separates "wrong model of the world" from + "market moved". Order-of-magnitude comparisons and band-usage fractions do; + "looks different" does not. +- Failing is for things that make the proposal wrong. Anything about execution + quality is a warning, in the `outcomes` pass. +- Add a unit test for the pure math in `tests/`, and re-run the checker against a + known-good historical proposal to confirm it still passes. diff --git a/packages/tooling/src/rebalance-validation/checks/basket.ts b/packages/tooling/src/rebalance-validation/checks/basket.ts new file mode 100644 index 0000000..f75cff4 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/checks/basket.ts @@ -0,0 +1,143 @@ +import type { DtfSdk } from "@reserve-protocol/sdk"; +import type { Address } from "viem"; + +import { getAddress } from "viem"; + +import type { ProposalContext } from "@/rebalance-validation/context"; +import type { Report } from "@/rebalance-validation/report"; + +import { ordersOfMagnitude } from "@/rebalance-validation/checks/prices"; +import { decodeStartRebalance, recoverTokenInputs } from "@/rebalance-validation/start-rebalance"; + +/** Weight changes past this are the mispriced-asset / wrong-decimals signature, not a rebalance. */ +export const WEIGHT_MAGNITUDE_FAIL = 0.5; + +/** + * Membership diff against the live basket. Additions are the entire scam-token + * risk surface and removals move the whole position, so both are always + * surfaced; an existing asset missing from the calldata is worse — it is + * excluded from the rebalance and silently stranded. + */ +export function checkBasketMembership(report: Report, context: ProposalContext): void { + const proposed = new Map(context.tokens.map((token) => [token.address, token])); + const held = [...context.currentBalances.entries()].filter(([, balance]) => balance > 0n).map(([address]) => address); + const additions = context.tokens.filter((token) => !context.currentBalances.has(token.address)); + const stranded = held.filter((address) => !proposed.has(address)); + const exits = context.rebalance.tokens.filter( + (token) => token.weight.spot === 0n && (context.currentBalances.get(token.token) ?? 0n) > 0n, + ); + + report.record( + "disasters", + stranded.length === 0 ? "pass" : "fail", + stranded.length === 0 + ? `every held asset appears in the calldata (${held.length} held, ${context.tokens.length} proposed)` + : "held assets are missing from the calldata and would be stranded", + stranded.join(", ") || undefined, + ); + + if (additions.length > 0 || exits.length > 0) { + report.record( + "disasters", + "warn", + "basket membership changes in this rebalance", + [ + additions.length > 0 ? `added: ${additions.map((token) => `${token.symbol} ${token.address}`).join(", ")}` : "", + exits.length > 0 + ? `exited to zero weight: ${exits.map((token) => symbolOf(context, token.token)).join(", ")}` + : "", + ] + .filter(Boolean) + .join(" · "), + ); + } +} + +export type PreviousRebalance = { + readonly title: string; + readonly unitsPerShare: ReadonlyMap; +}; + +/** + * Loads the most recent executed rebalance for the same DTF, so the new weights + * can be sanity-checked against weights that already survived execution. + */ +export async function fetchPreviousRebalance( + sdk: DtfSdk, + context: ProposalContext, +): Promise { + const address = getAddress(context.dtf.id); + const candidates = await sdk.index.getProposals({ address, chainId: context.chainId, limit: 20 }); + + for (const candidate of candidates) { + if (candidate.id === context.proposal.id || candidate.votingState.state !== "EXECUTED") continue; + const detail = await sdk.index.getProposal({ address, chainId: context.chainId, proposalId: candidate.id }); + const decoded = detail.targets.flatMap((target, index) => { + const callData = detail.calldatas[index]; + const action = callData ? decodeStartRebalance(target, callData) : undefined; + + return action ? [action] : []; + })[0]; + if (!decoded) continue; + const decimalsByToken = new Map(context.tokens.map((token) => [token.address, token.decimals])); + + return { + title: detail.description.split("\n")[0]?.replace(/^#+\s*/, "") ?? candidate.id, + unitsPerShare: new Map( + decoded.tokens.flatMap((token) => { + const decimals = decimalsByToken.get(token.token); + + return decimals === undefined + ? [] + : [[token.token, recoverTokenInputs(token, decimals).wholeTokensPerShare] as const]; + }), + ), + }; + } + + return undefined; +} + +/** + * Compares per-share units with the last executed rebalance. Real rebalances + * move weights by percent; an order-of-magnitude move means the model of the + * world changed, not the target. + */ +export function checkWeightHistory( + report: Report, + context: ProposalContext, + previous: PreviousRebalance | undefined, +): void { + if (!previous) { + report.record("disasters", "warn", "no previous executed rebalance to compare weights against"); + + return; + } + + const jumps: string[] = []; + let worst = { symbol: "-", magnitudes: 0 }; + + for (const [index, token] of context.rebalance.tokens.entries()) { + const meta = context.tokens[index]!; + const before = previous.unitsPerShare.get(token.token); + if (before === undefined) continue; + const after = recoverTokenInputs(token, meta.decimals).wholeTokensPerShare; + const magnitudes = ordersOfMagnitude(after, before); + if (magnitudes > worst.magnitudes) worst = { symbol: meta.symbol, magnitudes }; + if (magnitudes >= WEIGHT_MAGNITUDE_FAIL) { + jumps.push(`${meta.symbol}: ${before} -> ${after} units/share (${magnitudes.toFixed(2)} orders)`); + } + } + + report.record( + "disasters", + jumps.length === 0 ? "pass" : "fail", + jumps.length === 0 + ? `per-share units in line with "${previous.title}" (worst ${worst.symbol} ${worst.magnitudes.toFixed(2)} orders)` + : `per-share units jump by an order of magnitude vs "${previous.title}"`, + jumps.join(" · ") || undefined, + ); +} + +const symbolOf = (context: ProposalContext, address: Address): string => + context.tokens.find((token) => token.address === address)?.symbol ?? address; diff --git a/packages/tooling/src/rebalance-validation/checks/governance.ts b/packages/tooling/src/rebalance-validation/checks/governance.ts new file mode 100644 index 0000000..bba9ba3 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/checks/governance.ts @@ -0,0 +1,63 @@ +import { getAddress, isAddressEqual } from "viem"; + +import type { ProposalContext } from "@/rebalance-validation/context"; +import type { Report } from "@/rebalance-validation/report"; + +/** + * A routine rebalance is exactly one `startRebalance` on the DTF, submitted + * through the trading governor whose timelock holds REBALANCE_MANAGER. A wrong + * governor produces a proposal that can pass and then revert on execution; + * anything bundled alongside the rebalance is a different review entirely. + */ +export function checkGovernanceRouting(report: Report, context: ProposalContext): void { + const { proposal, rebalance, dtf, otherActions } = context; + + if (otherActions.length > 0) { + report.record( + "disasters", + "fail", + "proposal bundles actions other than startRebalance", + otherActions.map((action) => `${action.target} -> ${action.functionName}`).join(" · "), + ); + } else { + report.record("disasters", "pass", `single startRebalance action (${rebalance.abiLabel} ABI)`); + } + + if (!isAddressEqual(rebalance.target, getAddress(dtf.id))) { + report.record("disasters", "fail", "startRebalance targets another contract", rebalance.target); + } + + const approvers = [...dtf.roles.rebalance.auctionApprovers].map((address) => getAddress(address)); + const timelock = getAddress(proposal.timelock); + const isRebalanceGovernor = approvers.some((approver) => isAddressEqual(approver, timelock)); + report.record( + "disasters", + isRebalanceGovernor ? "pass" : "fail", + isRebalanceGovernor + ? "routed through the trading governor whose timelock is REBALANCE_MANAGER" + : "proposal governor's timelock does not hold REBALANCE_MANAGER", + `governor ${proposal.governance} -> timelock ${timelock} · approvers ${approvers.join(", ") || "none"}`, + ); +} + +/** + * `ttl > auctionLauncherWindow` leaves a tail in which anyone can open the + * auction. That is a deliberate choice per DTF, so it is reported rather than + * judged — but a TTL shorter than the window is always wrong. + */ +export function checkAuctionTiming(report: Report, context: ProposalContext): void { + const { auctionLauncherWindow, ttl } = context.rebalance; + const hours = (seconds: bigint) => `${Number(seconds) / 3600}h`; + const openWindow = ttl - auctionLauncherWindow; + + report.record( + "outcomes", + ttl < auctionLauncherWindow ? "fail" : openWindow > 0n ? "warn" : "pass", + ttl < auctionLauncherWindow + ? "ttl expires before the auction launcher window closes" + : openWindow > 0n + ? "permissionless tail: anyone can open the auction after the launcher window" + : "auction launcher has the whole rebalance window", + `launcher window ${hours(auctionLauncherWindow)} · ttl ${hours(ttl)} · permissionless tail ${hours(openWindow)}`, + ); +} diff --git a/packages/tooling/src/rebalance-validation/checks/identity.ts b/packages/tooling/src/rebalance-validation/checks/identity.ts new file mode 100644 index 0000000..297df59 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/checks/identity.ts @@ -0,0 +1,46 @@ +import type { ProposalContext } from "@/rebalance-validation/context"; +import type { Report } from "@/rebalance-validation/report"; +import type { ListedCoin } from "@/rebalance-validation/sources/token-identity"; + +import { symbolMatchesListing } from "@/rebalance-validation/sources/token-identity"; + +/** + * Checks each basket address against an outside address→coin map. A look-alike + * contract with the right symbol prices and trades like nothing, so identity has + * to be established off-protocol; a symbol that maps to a different coin is the + * loudest possible signal and fails the run. + */ +export function checkTokenIdentity( + report: Report, + context: ProposalContext, + listed: ReadonlyMap, +): void { + const mismatched: string[] = []; + const unlisted: string[] = []; + + for (const token of context.tokens) { + const coin = listed.get(token.address.toLowerCase()); + if (!coin) { + unlisted.push(`${token.symbol} ${token.address}`); + } else if (!symbolMatchesListing(token.symbol, coin.symbol)) { + mismatched.push(`${token.symbol} ${token.address} is listed as ${coin.symbol} (${coin.id})`); + } + } + + report.record( + "disasters", + mismatched.length === 0 ? "pass" : "fail", + mismatched.length === 0 + ? "every listed basket address matches its symbol on an independent source" + : "basket address is listed under a different symbol", + mismatched.join(" · ") || undefined, + ); + if (unlisted.length > 0) { + report.record( + "disasters", + "warn", + "basket addresses with no independent listing — verify by hand", + unlisted.join(" · "), + ); + } +} diff --git a/packages/tooling/src/rebalance-validation/checks/library.ts b/packages/tooling/src/rebalance-validation/checks/library.ts new file mode 100644 index 0000000..f2bccb7 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/checks/library.ts @@ -0,0 +1,114 @@ +import { buildIndexDtfStartRebalanceArgs } from "@reserve-protocol/sdk"; + +import type { ProposalContext } from "@/rebalance-validation/context"; +import type { Report } from "@/rebalance-validation/report"; + +import { formatPercent } from "@/rebalance-validation/report"; +import { recoverTokenInputs } from "@/rebalance-validation/start-rebalance"; + +export type RecoveredBasket = { + readonly targetShares: readonly bigint[]; + readonly prices: readonly number[]; + readonly priceErrors: readonly number[]; + readonly maxAuctionSizesUsd: readonly number[]; + readonly shareValueUsd: number; +}; + +/** + * Recovers the inputs the proposer fed the rebalance library from the encoded + * ranges: target shares (D18) from `weight.spot * price`, prices and price + * errors from the price range, and max auction sizes from `{tok}` back to USD. + */ +export function recoverBasket(context: ProposalContext): RecoveredBasket { + const recovered = context.rebalance.tokens.map((token, index) => + recoverTokenInputs(token, context.tokens[index]!.decimals), + ); + const valuesUsd = recovered.map((token) => token.wholeTokensPerShare * token.price); + const shareValueUsd = valuesUsd.reduce((total, value) => total + value, 0); + + return { + targetShares: valuesUsd.map((value) => BigInt(Math.round((value / shareValueUsd) * 1e18))), + prices: recovered.map((token) => token.price), + priceErrors: recovered.map((token) => token.priceError), + maxAuctionSizesUsd: context.rebalance.tokens.map( + (token, index) => + (Number(token.maxAuctionSize) / 10 ** context.tokens[index]!.decimals) * recovered[index]!.price, + ), + shareValueUsd, + }; +} + +const WEIGHT_TOLERANCE = 2e-6; + +/** + * Re-derives the whole action from the recovered inputs with the same library + * the propose flow uses. Matching output means the calldata is unmodified + * library output; a mismatch means it was hand-edited or built by something + * else, which is the only way the encoded ranges can disagree with the intent. + */ +export function checkLibraryReproduction(report: Report, context: ProposalContext, basket: RecoveredBasket): void { + const { rebalance, tokens, supply, currentBalances, dtf } = context; + let derived: ReturnType; + + try { + derived = buildIndexDtfStartRebalanceArgs({ + tokens: tokens.map((token, index) => ({ + address: token.address, + decimals: token.decimals, + price: basket.prices[index]!, + })), + supply, + balances: tokens.map((token) => currentBalances.get(token.address) ?? 0n), + basket: { type: "shares", shares: [...basket.targetShares] }, + priceErrors: [...basket.priceErrors], + maxAuctionSizesUsd: [...basket.maxAuctionSizesUsd], + weightControl: dtf.rebalance.weightControl, + }); + } catch (error) { + report.record("disasters", "fail", "could not re-derive startRebalance from the library", String(error)); + + return; + } + + const drift = (encoded: bigint, expected: bigint) => + expected === 0n ? (encoded === 0n ? 0 : 1) : Math.abs(Number(encoded - expected) / Number(expected)); + const mismatches = rebalance.tokens.flatMap((token, index) => { + const expected = derived.tokens[index]; + if (!expected) return [`${tokens[index]!.symbol}: missing from re-derivation`]; + const worst = Math.max( + drift(token.weight.low, expected.weight.low), + drift(token.weight.spot, expected.weight.spot), + drift(token.weight.high, expected.weight.high), + drift(token.price.low, expected.price.low), + drift(token.price.high, expected.price.high), + ); + + return worst > WEIGHT_TOLERANCE ? [`${tokens[index]!.symbol}: ranges drift by ${formatPercent(worst, 5)}`] : []; + }); + + report.record( + "disasters", + mismatches.length === 0 ? "pass" : "fail", + mismatches.length === 0 + ? "weights and price ranges reproduce from the rebalance library" + : "encoded ranges do not reproduce from the rebalance library", + mismatches.join(" · ") || `${rebalance.tokens.length} tokens · price errors ${describeErrors(basket)}`, + ); + + const limitDrift = Math.max( + drift(rebalance.limits.low, derived.limits.low), + drift(rebalance.limits.spot, derived.limits.spot), + drift(rebalance.limits.high, derived.limits.high), + ); + report.record( + "disasters", + limitDrift <= WEIGHT_TOLERANCE ? "pass" : "fail", + limitDrift <= WEIGHT_TOLERANCE + ? "rebalance limits reproduce from the rebalance library" + : "rebalance limits do not reproduce from the rebalance library", + `encoded high ${Number(rebalance.limits.high) / 1e18} vs derived ${Number(derived.limits.high) / 1e18}`, + ); +} + +const describeErrors = (basket: RecoveredBasket): string => + [...new Set(basket.priceErrors.map((error) => formatPercent(error, 0)))].join(", "); diff --git a/packages/tooling/src/rebalance-validation/checks/liquidity.ts b/packages/tooling/src/rebalance-validation/checks/liquidity.ts new file mode 100644 index 0000000..514ef48 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/checks/liquidity.ts @@ -0,0 +1,157 @@ +import type { DtfSdk, IndexDtfRebalanceLiquidityTrade } from "@reserve-protocol/sdk"; +import type { Address } from "viem"; + +import type { ProposalContext } from "@/rebalance-validation/context"; +import type { Report } from "@/rebalance-validation/report"; +import type { PoolQuote } from "@/rebalance-validation/sources/pool-prices"; + +import { formatPercent, formatUsd } from "@/rebalance-validation/report"; +import { recoverTokenInputs } from "@/rebalance-validation/start-rebalance"; + +/** + * Trading-desk thresholds: a leg past these needs preparation before the auction + * opens. The liquidity endpoint reports price impact in percent, signed by trade + * direction. + */ +export const PRICE_IMPACT_FLAG_PERCENT = 5; +export const TRADE_SIZE_FLAG_USD = 10_000; +export const MIN_POOL_LIQUIDITY_USD = 50_000; + +export type TradeLeg = { + readonly address: Address; + readonly symbol: string; + readonly side: "buy" | "sell"; + readonly amountUsd: number; + readonly price: number; + readonly decimals: number; +}; + +/** + * Sizes each leg from the delta between the live balance and the proposed + * per-share units, valued at pool prices — the same reason the basket check uses + * pool prices: an API mispricing would size the trades wrong in exactly the way + * we are trying to detect. + */ +export function buildTradeLegs(context: ProposalContext, quotes: ReadonlyMap): readonly TradeLeg[] { + const shares = Number(context.supply) / 1e18; + + return context.rebalance.tokens.flatMap((token, index) => { + const meta = context.tokens[index]!; + const recovered = recoverTokenInputs(token, meta.decimals); + const price = quotes.get(token.token)?.price ?? recovered.price; + const currentUnits = Number(context.currentBalances.get(meta.address) ?? 0n) / 10 ** meta.decimals; + const deltaUnits = recovered.wholeTokensPerShare * shares - currentUnits; + const amountUsd = Math.abs(deltaUnits) * price; + if (amountUsd < 1) return []; + + return [ + { + address: meta.address, + symbol: meta.symbol, + side: deltaUnits > 0 ? ("buy" as const) : ("sell" as const), + amountUsd, + price, + decimals: meta.decimals, + }, + ]; + }); +} + +export function reportTurnover(report: Report, legs: readonly TradeLeg[], aumUsd: number): void { + const buys = legs.filter((leg) => leg.side === "buy").reduce((total, leg) => total + leg.amountUsd, 0); + const sells = legs.filter((leg) => leg.side === "sell").reduce((total, leg) => total + leg.amountUsd, 0); + const turnover = Math.min(buys, sells); + + report.record( + "outcomes", + "pass", + `turnover ${formatUsd(turnover)} — ${formatPercent(aumUsd > 0 ? turnover / aumUsd : 0)} of ${formatUsd(aumUsd)} AUM`, + `${formatUsd(buys)} to buy, ${formatUsd(sells)} to sell`, + ); + + const large = legs.filter((leg) => leg.amountUsd > TRADE_SIZE_FLAG_USD); + if (large.length > 0) { + report.record( + "outcomes", + "warn", + `legs above ${formatUsd(TRADE_SIZE_FLAG_USD)} — buy inventory before the auction opens`, + large.map((leg) => `${leg.symbol} ${leg.side} ${formatUsd(leg.amountUsd)}`).join(" · "), + ); + } +} + +/** Every constituent needs a liquidity floor, not just the ones being traded this month. */ +export function checkPoolDepth( + report: Report, + context: ProposalContext, + quotes: ReadonlyMap, +): void { + const thin = context.tokens.flatMap((token) => { + const quote = quotes.get(token.address); + if (!quote) return []; + + return quote.liquidityUsd < MIN_POOL_LIQUIDITY_USD + ? [`${token.symbol} ${formatUsd(quote.liquidityUsd)} (deepest pool ${quote.topPool.dex} ${quote.topPool.pair})`] + : []; + }); + + report.record( + "outcomes", + thin.length === 0 ? "pass" : "warn", + thin.length === 0 + ? `every constituent has at least ${formatUsd(MIN_POOL_LIQUIDITY_USD)} of pooled liquidity` + : `constituents below ${formatUsd(MIN_POOL_LIQUIDITY_USD)} of pooled liquidity`, + thin.join(" · ") || undefined, + ); +} + +/** + * Routes the legs through the same production endpoint the propose flow uses, so + * the reviewer sees the price impact the auction would actually face. + */ +export async function checkTradeLiquidity( + report: Report, + sdk: DtfSdk, + context: ProposalContext, + legs: readonly TradeLeg[], + nativePrice: number, +): Promise { + const trades: readonly IndexDtfRebalanceLiquidityTrade[] = legs.map((leg) => ({ + address: leg.address, + side: leg.side, + amountUsd: leg.amountUsd, + price: leg.price, + decimals: leg.decimals, + })); + if (trades.length === 0) return; + + let liquidity; + try { + liquidity = await sdk.index.getRebalanceLiquidity({ chainId: context.chainId, nativePrice, trades }); + } catch (error) { + report.record("outcomes", "warn", "liquidity route unavailable — check impact by hand", String(error)); + + return; + } + + const symbols = new Map(context.tokens.map((token) => [token.address.toLowerCase(), token.symbol])); + const flagged = liquidity.assets.filter( + (asset) => + Math.abs(asset.liquidity.priceImpact) > PRICE_IMPACT_FLAG_PERCENT || + ["low", "insufficient", "error", "failed", "unknown"].includes(asset.liquidity.level), + ); + + report.record( + "outcomes", + flagged.length === 0 ? "pass" : "warn", + flagged.length === 0 + ? `every leg routes under ${PRICE_IMPACT_FLAG_PERCENT}% price impact (${liquidity.assets.length} legs)` + : `legs above ${PRICE_IMPACT_FLAG_PERCENT}% price impact or with weak routes`, + flagged + .map( + (asset) => + `${symbols.get(asset.address.toLowerCase()) ?? asset.address} ${asset.side} ${formatUsd(asset.amountUsd)} -> impact ${asset.liquidity.priceImpact.toFixed(2)}%, level ${asset.liquidity.level}, score ${asset.liquidity.score.toFixed(0)}`, + ) + .join(" · ") || undefined, + ); +} diff --git a/packages/tooling/src/rebalance-validation/checks/prices.ts b/packages/tooling/src/rebalance-validation/checks/prices.ts new file mode 100644 index 0000000..45de262 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/checks/prices.ts @@ -0,0 +1,114 @@ +import type { Address } from "viem"; + +import type { RecoveredBasket } from "@/rebalance-validation/checks/library"; +import type { ProposalContext } from "@/rebalance-validation/context"; +import type { Report } from "@/rebalance-validation/report"; +import type { PoolQuote } from "@/rebalance-validation/sources/pool-prices"; + +import { formatPercent, formatUsd } from "@/rebalance-validation/report"; +import { recoverTokenInputs } from "@/rebalance-validation/start-rebalance"; + +/** A pool price this far from the encoded price is the signature of wrong decimals or a wrong token. */ +export const MAGNITUDE_FAIL = 0.5; +/** Inside the band but past this much of it, the auction can still fill badly. */ +export const BAND_USAGE_WARN = 0.5; +/** Basket shares recomputed at pool prices may differ by rounding and stale pools, not by this much. */ +export const BASKET_SHARE_FAIL = 0.01; + +export const ordersOfMagnitude = (a: number, b: number): number => + a > 0 && b > 0 ? Math.abs(Math.log10(a / b)) : Number.POSITIVE_INFINITY; + +/** + * Fraction of the encoded price band consumed by the pool price: 0 at the + * geometric mean, 1 at either edge. + */ +export const bandUsage = (poolPrice: number, low: number, high: number, price: number): number => + poolPrice >= price ? (poolPrice - price) / (high - price) : (price - poolPrice) / (price - low); + +/** + * Highest-value check in the whole review: the encoded price of every asset, + * against a price from liquidity pools rather than from the API the proposal was + * built with. Correlated errors between our price feed and our own calldata are + * invisible to any self-consistency check, and pricing one asset wrong is how a + * rebalance moves real value into the wrong place. + */ +export function checkPoolPrices( + report: Report, + context: ProposalContext, + quotes: ReadonlyMap, +): void { + const outside: string[] = []; + const wideBand: string[] = []; + const missing: string[] = []; + + for (const [index, token] of context.rebalance.tokens.entries()) { + const meta = context.tokens[index]!; + const quote = quotes.get(token.token); + if (!quote || !Number.isFinite(quote.price) || quote.price <= 0) { + missing.push(meta.symbol); + continue; + } + const { price, priceLow, priceHigh } = recoverTokenInputs(token, meta.decimals); + const magnitudes = ordersOfMagnitude(quote.price, price); + const usage = bandUsage(quote.price, priceLow, priceHigh, price); + + if (quote.price < priceLow || quote.price > priceHigh || magnitudes >= MAGNITUDE_FAIL) { + outside.push( + `${meta.symbol}: pool ${quote.price} vs encoded ${price} (${magnitudes.toFixed(2)} orders, band ${priceLow}–${priceHigh})`, + ); + } else if (usage > BAND_USAGE_WARN) { + wideBand.push(`${meta.symbol}: pool price uses ${formatPercent(usage, 0)} of the encoded band`); + } + } + + report.record( + "disasters", + outside.length === 0 ? "pass" : "fail", + outside.length === 0 + ? "pool prices sit inside every encoded price range (independent of the Reserve API)" + : "pool price disagrees with the encoded price range", + outside.join(" · ") || undefined, + ); + if (wideBand.length > 0) { + report.record("disasters", "warn", "pool price near the edge of the encoded band", wideBand.join(" · ")); + } + if (missing.length > 0) { + report.record("disasters", "warn", "no pool price found — verify by hand", missing.join(", ")); + } +} + +/** + * Re-values the proposed basket at pool prices. If one asset is mispriced, its + * target share moves, so this catches value being shifted into the wrong place + * even when every individual price still looks plausible. + */ +export function checkBasketSharesAtPoolPrices( + report: Report, + context: ProposalContext, + basket: RecoveredBasket, + quotes: ReadonlyMap, +): void { + const priced = context.rebalance.tokens.map((token, index) => { + const meta = context.tokens[index]!; + const recovered = recoverTokenInputs(token, meta.decimals); + const poolPrice = quotes.get(token.token)?.price; + + return { symbol: meta.symbol, units: recovered.wholeTokensPerShare, price: poolPrice ?? recovered.price }; + }); + const valuesUsd = priced.map((token) => token.units * token.price); + const total = valuesUsd.reduce((sum, value) => sum + value, 0); + const drifts = priced.map((token, index) => ({ + symbol: token.symbol, + drift: Math.abs(valuesUsd[index]! / total - Number(basket.targetShares[index]!) / 1e18), + })); + const worst = drifts.reduce((max, entry) => (entry.drift > max.drift ? entry : max), { symbol: "-", drift: 0 }); + + report.record( + "disasters", + worst.drift <= BASKET_SHARE_FAIL ? "pass" : "fail", + worst.drift <= BASKET_SHARE_FAIL + ? `basket shares agree when valued at pool prices (worst ${worst.symbol} ${formatPercent(worst.drift)})` + : `basket share of ${worst.symbol} moves ${formatPercent(worst.drift)} when valued at pool prices`, + `share value ${formatUsd(basket.shareValueUsd)} at encoded prices vs ${formatUsd(total)} at pool prices`, + ); +} diff --git a/packages/tooling/src/rebalance-validation/cli.ts b/packages/tooling/src/rebalance-validation/cli.ts new file mode 100644 index 0000000..2bc146f --- /dev/null +++ b/packages/tooling/src/rebalance-validation/cli.ts @@ -0,0 +1,27 @@ +import { createDtfSdk } from "@reserve-protocol/sdk"; + +import { HUMAN_REVIEW_QUESTIONS } from "@/rebalance-validation/human-review"; +import { parseProposalUrl } from "@/rebalance-validation/proposal-url"; +import { validateRebalanceProposal } from "@/rebalance-validation/validate"; + +async function main(): Promise { + const input = process.argv[2]; + if (!input) { + console.error("usage: pnpm validate:rebalance "); + process.exit(2); + } + + const url = parseProposalUrl(input); + console.log(`Validating ${input}`); + const { report } = await validateRebalanceProposal(createDtfSdk(), url); + report.print(); + + console.log("\nStill needs a human"); + for (const question of HUMAN_REVIEW_QUESTIONS) { + console.log(` - ${question}`); + } + + process.exit(report.failures > 0 ? 1 : 0); +} + +await main(); diff --git a/packages/tooling/src/rebalance-validation/context.ts b/packages/tooling/src/rebalance-validation/context.ts new file mode 100644 index 0000000..8fdb0b3 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/context.ts @@ -0,0 +1,94 @@ +import type { DtfSdk, IndexDtf, IndexDtfProposalDetail, SupportedChainId } from "@reserve-protocol/sdk"; +import type { Address } from "viem"; + +import { erc20Abi, getAddress } from "viem"; + +import type { ParsedProposalUrl } from "@/rebalance-validation/proposal-url"; +import type { DecodedStartRebalance } from "@/rebalance-validation/start-rebalance"; + +import { decodeStartRebalance } from "@/rebalance-validation/start-rebalance"; + +export type BasketToken = { readonly address: Address; readonly symbol: string; readonly decimals: number }; + +export type ProposalContext = { + readonly chainId: SupportedChainId; + readonly dtf: IndexDtf; + readonly proposal: IndexDtfProposalDetail; + readonly rebalance: DecodedStartRebalance; + readonly otherActions: readonly { readonly target: Address; readonly functionName: string }[]; + readonly version: string; + readonly supply: bigint; + readonly currentBalances: ReadonlyMap; + readonly tokens: readonly BasketToken[]; +}; + +export async function loadProposalContext(sdk: DtfSdk, url: ParsedProposalUrl): Promise { + const { chainId, proposalId } = url; + const resolved = sdk.index.resolveAlias({ input: url.dtf, chainId }); + if (!resolved || !("address" in resolved)) { + throw new Error(`could not resolve a single DTF for "${url.dtf}" on chain ${chainId}`); + } + const address = getAddress(resolved.address); + const [dtf, proposal, version, supply, totalAssets] = await Promise.all([ + sdk.index.getDtf({ address, chainId }), + sdk.index.getProposal({ address, chainId, proposalId }), + sdk.index.getVersion({ address, chainId }), + sdk.index.getTotalSupply({ address, chainId }), + sdk.index.getTotalAssets({ address, chainId }), + ]); + + const rebalanceActions = proposal.targets.flatMap((target, index) => { + const callData = proposal.calldatas[index]; + const decoded = callData ? decodeStartRebalance(target, callData) : undefined; + + return decoded ? [decoded] : []; + }); + const rebalance = rebalanceActions[0]; + if (!rebalance) { + throw new Error(`proposal ${proposalId} contains no startRebalance action`); + } + const otherActions = proposal.decoded.calls + .filter((call) => call.functionName !== "startRebalance") + .map((call) => ({ target: call.target, functionName: call.functionName })) + .concat( + proposal.decoded.unknownCalls.map((call) => ({ target: call.target, functionName: "UNDECODABLE CALLDATA" })), + rebalanceActions.slice(1).map((extra) => ({ target: extra.target, functionName: "startRebalance (extra)" })), + ); + + return { + chainId, + dtf, + proposal, + rebalance, + otherActions, + version, + supply, + currentBalances: new Map(totalAssets.tokens.map((token, index) => [token, totalAssets.balances[index] ?? 0n])), + tokens: await fetchTokenMetadata( + sdk, + chainId, + rebalance.tokens.map((token) => token.token), + ), + }; +} + +async function fetchTokenMetadata( + sdk: DtfSdk, + chainId: SupportedChainId, + addresses: readonly Address[], +): Promise { + const client = sdk.client.viem.getPublicClient(chainId); + const metadata = await client.multicall({ + allowFailure: false, + contracts: addresses.flatMap((address) => [ + { address, abi: erc20Abi, functionName: "symbol" } as const, + { address, abi: erc20Abi, functionName: "decimals" } as const, + ]), + }); + + return addresses.map((address, index) => ({ + address, + symbol: metadata[index * 2] as string, + decimals: metadata[index * 2 + 1] as number, + })); +} diff --git a/packages/tooling/src/rebalance-validation/human-review.ts b/packages/tooling/src/rebalance-validation/human-review.ts new file mode 100644 index 0000000..8adc88c --- /dev/null +++ b/packages/tooling/src/rebalance-validation/human-review.ts @@ -0,0 +1,11 @@ +/** + * Questions no data source can settle. They are printed on every run so a clean + * report is never mistaken for a complete review. + */ +export const HUMAN_REVIEW_QUESTIONS: readonly string[] = [ + "Is the constituent list the mandate's official universe, or was it assembled by hand? Any large-cap name missing needs a reason.", + "For each added token: is this the canonical representation on this chain, or a bridged/synthetic wrapper with its own risk?", + "Are the maxAuctionSize values sized for these trades, or a leftover flat default?", + "Should the trading bot hold inventory for the flagged legs before the auction opens?", + "Is the permissionless tail (ttl beyond the launcher window) intended for this DTF?", +]; diff --git a/packages/tooling/src/rebalance-validation/proposal-url.ts b/packages/tooling/src/rebalance-validation/proposal-url.ts new file mode 100644 index 0000000..c819639 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/proposal-url.ts @@ -0,0 +1,28 @@ +import type { SupportedChainId } from "@reserve-protocol/sdk"; + +const CHAIN_BY_SLUG: Record = { + ethereum: 1, + base: 8453, + bsc: 56, +}; + +export type ParsedProposalUrl = { + readonly chainId: SupportedChainId; + readonly dtf: string; + readonly proposalId: string; +}; + +/** Parses an app.reserve.org governance proposal URL: //index-dtf//governance/proposal/. */ +export function parseProposalUrl(input: string): ParsedProposalUrl { + const match = /\/(ethereum|base|bsc)\/index-dtf\/([^/]+)\/governance\/proposal\/(\d+)/.exec(input); + if (!match) { + throw new Error(`not a governance proposal url: ${input}`); + } + const [, slug, dtf, proposalId] = match as unknown as [string, string, string, string]; + const chainId = CHAIN_BY_SLUG[slug]; + if (chainId === undefined) { + throw new Error(`unsupported chain in url: ${slug}`); + } + + return { chainId, dtf, proposalId }; +} diff --git a/packages/tooling/src/rebalance-validation/report.ts b/packages/tooling/src/rebalance-validation/report.ts new file mode 100644 index 0000000..d936e3a --- /dev/null +++ b/packages/tooling/src/rebalance-validation/report.ts @@ -0,0 +1,48 @@ +export type CheckLevel = "pass" | "warn" | "fail"; + +export type Check = { + readonly level: CheckLevel; + readonly label: string; + readonly detail?: string; +}; + +/** + * Two-pass ordering, deliberately: "preventing disasters" (value landing in the + * wrong place) gates the review, "optimizing outcomes" (execution quality) only + * informs it. Only the first pass can fail a run. + */ +export type CheckPass = "disasters" | "outcomes"; + +export class Report { + private readonly checks: { pass: CheckPass; check: Check }[] = []; + + record(pass: CheckPass, level: CheckLevel, label: string, detail?: string): void { + this.checks.push({ pass, check: { level, label, ...(detail === undefined ? {} : { detail }) } }); + } + + get failures(): number { + return this.checks.filter(({ pass, check }) => pass === "disasters" && check.level === "fail").length; + } + + print(log: (line: string) => void = console.log): void { + for (const pass of ["disasters", "outcomes"] as const) { + const passChecks = this.checks.filter((entry) => entry.pass === pass); + if (passChecks.length === 0) continue; + log(`\n${pass === "disasters" ? "Preventing disasters" : "Optimizing outcomes"}`); + for (const { check } of passChecks) { + log(` ${{ pass: " ok ", warn: "WARN", fail: "FAIL" }[check.level]} ${check.label}`); + if (check.detail) log(` ${check.detail}`); + } + } + const warns = this.checks.filter((entry) => entry.check.level === "warn").length; + const fails = this.checks.filter((entry) => entry.check.level === "fail").length; + log( + `\n${this.failures ? "BLOCKED" : warns || fails ? "OK WITH FLAGS" : "OK"} — ${this.checks.length - warns - fails} pass, ${warns} warn, ${fails} fail`, + ); + } +} + +export const formatUsd = (value: number): string => + value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }); + +export const formatPercent = (value: number, digits = 3): string => `${(value * 100).toFixed(digits)}%`; diff --git a/packages/tooling/src/rebalance-validation/sources/pool-prices.ts b/packages/tooling/src/rebalance-validation/sources/pool-prices.ts new file mode 100644 index 0000000..b8b716b --- /dev/null +++ b/packages/tooling/src/rebalance-validation/sources/pool-prices.ts @@ -0,0 +1,79 @@ +import type { SupportedChainId } from "@reserve-protocol/sdk"; +import type { Address } from "viem"; + +import { getAddress, isAddress } from "viem"; + +const DEXSCREENER_CHAIN: Record = { + 1: "ethereum", + 8453: "base", + 56: "bsc", +}; + +export type PoolQuote = { + readonly price: number; + readonly liquidityUsd: number; + readonly topPool: { readonly dex: string; readonly pair: string; readonly liquidityUsd: number }; +}; + +type DexScreenerPair = { + readonly chainId?: string; + readonly dexId?: string; + readonly baseToken?: { readonly address?: string; readonly symbol?: string }; + readonly quoteToken?: { readonly symbol?: string }; + readonly priceUsd?: string; + readonly liquidity?: { readonly usd?: number }; +}; + +/** + * Deepest-pool USD price and pooled liquidity per token, from DEXScreener. + * + * Deliberately a different provider than the Reserve API the proposal was built + * from: an independent mark is the only thing that catches our own price feed + * being wrong. One token per request — the endpoint caps the response at 30 + * *pairs*, so a batched query silently returns nothing for most of the batch. + */ +export async function fetchPoolQuotes( + chainId: SupportedChainId, + tokens: readonly Address[], +): Promise> { + const quotes = new Map(); + + for (const token of tokens) { + const response = await fetch(`https://api.dexscreener.com/latest/dex/tokens/${token}`, { + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) continue; + const { pairs } = (await response.json()) as { pairs?: readonly DexScreenerPair[] | null }; + const quote = selectDeepestPool(pairs ?? [], chainId, token); + if (quote) quotes.set(token, quote); + } + + return quotes; +} + +export function selectDeepestPool( + pairs: readonly DexScreenerPair[], + chainId: SupportedChainId, + token: Address, +): PoolQuote | undefined { + const onChain = pairs + .filter((pair) => pair.chainId === DEXSCREENER_CHAIN[chainId] && pair.priceUsd) + .filter((pair) => { + const base = pair.baseToken?.address; + + return typeof base === "string" && isAddress(base) && getAddress(base) === token; + }) + .sort((a, b) => (b.liquidity?.usd ?? 0) - (a.liquidity?.usd ?? 0)); + const deepest = onChain[0]; + if (!deepest) return undefined; + + return { + price: Number(deepest.priceUsd), + liquidityUsd: onChain.reduce((total, pair) => total + (pair.liquidity?.usd ?? 0), 0), + topPool: { + dex: deepest.dexId ?? "unknown", + pair: `${deepest.baseToken?.symbol ?? "?"}/${deepest.quoteToken?.symbol ?? "?"}`, + liquidityUsd: deepest.liquidity?.usd ?? 0, + }, + }; +} diff --git a/packages/tooling/src/rebalance-validation/sources/token-identity.ts b/packages/tooling/src/rebalance-validation/sources/token-identity.ts new file mode 100644 index 0000000..c12b76b --- /dev/null +++ b/packages/tooling/src/rebalance-validation/sources/token-identity.ts @@ -0,0 +1,59 @@ +import type { SupportedChainId } from "@reserve-protocol/sdk"; + +const COINGECKO_PLATFORM: Record = { + 1: "ethereum", + 8453: "base", + 56: "binance-smart-chain", +}; + +export type ListedCoin = { readonly id: string; readonly symbol: string }; + +/** + * Contract-address → coin map from CoinGecko, used to check that a basket + * address is the contract the outside world associates with that symbol. + * + * A listing is evidence, not proof: it catches a look-alike or typo'd address, + * and it does not cover every legitimate bridged wrapper, so an unlisted + * address is a prompt to verify by hand rather than a verdict. + */ +export async function fetchListedCoinsByAddress(chainId: SupportedChainId): Promise> { + const platform = COINGECKO_PLATFORM[chainId]; + const response = await fetch("https://api.coingecko.com/api/v3/coins/list?include_platform=true", { + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) { + throw new Error(`coingecko coins/list ${response.status}`); + } + const coins = (await response.json()) as readonly { + readonly id: string; + readonly symbol: string; + readonly platforms?: Record; + }[]; + const byAddress = new Map(); + + for (const coin of coins) { + const address = platform === undefined ? undefined : coin.platforms?.[platform]; + if (address) { + byAddress.set(address.toLowerCase(), { id: coin.id, symbol: coin.symbol.toUpperCase() }); + } + } + + return byAddress; +} + +/** Bridged wrappers keep the underlying's ticker under a prefixed listing symbol. */ +const SYMBOL_ALIASES: Record = { + BTCB: "BTC", + WBNB: "BNB", + WETH: "ETH", +}; + +export const symbolMatchesListing = (basketSymbol: string, listedSymbol: string): boolean => { + const normalize = (symbol: string) => { + const upper = symbol.toUpperCase(); + + return SYMBOL_ALIASES[upper] ?? upper; + }; + + return normalize(basketSymbol) === normalize(listedSymbol); +}; diff --git a/packages/tooling/src/rebalance-validation/start-rebalance.ts b/packages/tooling/src/rebalance-validation/start-rebalance.ts new file mode 100644 index 0000000..b04b713 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/start-rebalance.ts @@ -0,0 +1,82 @@ +import type { Abi, Address, Hex } from "viem"; + +import { dtfIndexAbi, dtfIndexAbiV4 } from "@reserve-protocol/sdk"; +import { decodeFunctionData, getAddress } from "viem"; + +export type WeightRange = { readonly low: bigint; readonly spot: bigint; readonly high: bigint }; + +export type TokenRebalanceParams = { + readonly token: Address; + readonly weight: WeightRange; + readonly price: { readonly low: bigint; readonly high: bigint }; + readonly maxAuctionSize: bigint; + readonly inRebalance: boolean; +}; + +export type DecodedStartRebalance = { + readonly abiLabel: "v4" | "v5/v6"; + readonly target: Address; + readonly tokens: readonly TokenRebalanceParams[]; + readonly limits: WeightRange; + readonly auctionLauncherWindow: bigint; + readonly ttl: bigint; +}; + +const ABIS: readonly { readonly label: DecodedStartRebalance["abiLabel"]; readonly abi: Abi }[] = [ + { label: "v5/v6", abi: dtfIndexAbi as Abi }, + { label: "v4", abi: dtfIndexAbiV4 as Abi }, +]; + +/** + * Decodes a `startRebalance` action. The v4 and v5+ signatures differ, so both + * ABIs are tried; anything else returns undefined and is reported as a + * non-routine action rather than silently ignored. + */ +export function decodeStartRebalance(target: Address, callData: Hex): DecodedStartRebalance | undefined { + for (const { label, abi } of ABIS) { + try { + const decoded = decodeFunctionData({ abi, data: callData }); + if (decoded.functionName !== "startRebalance") continue; + const [tokens, limits, auctionLauncherWindow, ttl] = decoded.args as unknown as [ + readonly TokenRebalanceParams[], + WeightRange, + bigint, + bigint, + ]; + + return { + abiLabel: label, + target: getAddress(target), + tokens: tokens.map((token) => ({ ...token, token: getAddress(token.token) })), + limits, + auctionLauncherWindow, + ttl, + }; + } catch { + continue; + } + } + + return undefined; +} + +/** + * Recovers the proposer's inputs from the encoded ranges. `price` is + * D27{nanoUSD/tok} with `low = p*(1-e)` and `high = p/(1-e)`, so the geometric + * mean is the price and `1 - low/price` is the price-error preset; `weight.spot` + * is D27{tok/share}. + */ +export function recoverTokenInputs(token: TokenRebalanceParams, decimals: number) { + const scale = 10 ** (27 + 9 - decimals); + const low = Number(token.price.low) / scale; + const high = Number(token.price.high) / scale; + const price = Math.sqrt(low * high); + + return { + price, + priceLow: low, + priceHigh: high, + priceError: 1 - low / price, + wholeTokensPerShare: (Number(token.weight.spot) / 1e27 / 10 ** decimals) * 1e18, + }; +} diff --git a/packages/tooling/src/rebalance-validation/validate.ts b/packages/tooling/src/rebalance-validation/validate.ts new file mode 100644 index 0000000..3cea7e2 --- /dev/null +++ b/packages/tooling/src/rebalance-validation/validate.ts @@ -0,0 +1,86 @@ +import type { DtfSdk } from "@reserve-protocol/sdk"; + +import type { ProposalContext } from "@/rebalance-validation/context"; +import type { ParsedProposalUrl } from "@/rebalance-validation/proposal-url"; + +import { + checkBasketMembership, + checkWeightHistory, + fetchPreviousRebalance, +} from "@/rebalance-validation/checks/basket"; +import { checkAuctionTiming, checkGovernanceRouting } from "@/rebalance-validation/checks/governance"; +import { checkTokenIdentity } from "@/rebalance-validation/checks/identity"; +import { checkLibraryReproduction, recoverBasket } from "@/rebalance-validation/checks/library"; +import { + buildTradeLegs, + checkPoolDepth, + checkTradeLiquidity, + reportTurnover, +} from "@/rebalance-validation/checks/liquidity"; +import { checkBasketSharesAtPoolPrices, checkPoolPrices } from "@/rebalance-validation/checks/prices"; +import { loadProposalContext } from "@/rebalance-validation/context"; +import { Report, formatUsd } from "@/rebalance-validation/report"; +import { fetchPoolQuotes } from "@/rebalance-validation/sources/pool-prices"; +import { fetchListedCoinsByAddress } from "@/rebalance-validation/sources/token-identity"; + +const NATIVE_TOKEN_BY_CHAIN: Record = { + 1: "ETH", + 8453: "ETH", + 56: "BNB", +}; + +export type ValidationResult = { readonly report: Report; readonly context: ProposalContext }; + +/** + * Runs the two-pass review: disaster checks that gate the proposal, then + * execution checks that inform it. Independent data (pools, listings) is fetched + * once and shared by the checks that need it. + */ +export async function validateRebalanceProposal(sdk: DtfSdk, url: ParsedProposalUrl): Promise { + const report = new Report(); + const context = await loadProposalContext(sdk, url); + const basket = recoverBasket(context); + const addresses = context.tokens.map((token) => token.address); + + report.record( + "disasters", + "pass", + `${context.dtf.token.symbol} v${context.version} · ${context.dtf.rebalance.weightControl ? "NATIVE" : "TRACKING"} · ${context.tokens.length} constituents`, + `proposal ${context.proposal.id} (${context.proposal.votingState.state}) · supply ${(Number(context.supply) / 1e18).toFixed(2)} shares · AUM ${formatUsd(aumUsd(context, basket.shareValueUsd))}`, + ); + + checkGovernanceRouting(report, context); + checkBasketMembership(report, context); + checkLibraryReproduction(report, context, basket); + + const [quotes, listed, previous] = await Promise.all([ + fetchPoolQuotes(context.chainId, addresses), + fetchListedCoinsByAddress(context.chainId).catch(() => new Map()), + fetchPreviousRebalance(sdk, context).catch(() => undefined), + ]); + + checkPoolPrices(report, context, quotes); + checkBasketSharesAtPoolPrices(report, context, basket, quotes); + checkWeightHistory(report, context, previous); + checkTokenIdentity(report, context, listed); + + checkAuctionTiming(report, context); + const legs = buildTradeLegs(context, quotes); + reportTurnover(report, legs, aumUsd(context, basket.shareValueUsd)); + checkPoolDepth(report, context, quotes); + await checkTradeLiquidity(report, sdk, context, legs, await nativePrice(sdk, context)); + + return { report, context }; +} + +const aumUsd = (context: ProposalContext, shareValueUsd: number): number => + (Number(context.supply) / 1e18) * shareValueUsd; + +async function nativePrice(sdk: DtfSdk, context: ProposalContext): Promise { + const symbol = NATIVE_TOKEN_BY_CHAIN[context.chainId] ?? "ETH"; + const wrapped = context.tokens.find((token) => token.symbol.toUpperCase() === `W${symbol}`); + if (!wrapped) return 0; + const [price] = await sdk.client.api.getTokenPrices({ chainId: context.chainId, addresses: [wrapped.address] }); + + return price?.price ?? 0; +} diff --git a/packages/tooling/tests/rebalance-validation.test.ts b/packages/tooling/tests/rebalance-validation.test.ts new file mode 100644 index 0000000..253c516 --- /dev/null +++ b/packages/tooling/tests/rebalance-validation.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { bandUsage, ordersOfMagnitude } from "@/rebalance-validation/checks/prices"; +import { parseProposalUrl } from "@/rebalance-validation/proposal-url"; +import { Report } from "@/rebalance-validation/report"; +import { selectDeepestPool } from "@/rebalance-validation/sources/pool-prices"; +import { symbolMatchesListing } from "@/rebalance-validation/sources/token-identity"; +import { recoverTokenInputs } from "@/rebalance-validation/start-rebalance"; + +const shibToken = { + token: "0x2859e4544C4bB03966803b044A93563Bd2D0DD4D" as const, + // price = 5.032e-6 USD/wholeTok with a 75% price error, D27{nanoUSD/tok} + price: { low: 1_258_000_000_000n, high: 20_128_000_000_000n }, + weight: { low: 0n, spot: 0n, high: 0n }, + maxAuctionSize: 0n, + inRebalance: true, +}; + +describe("parseProposalUrl", () => { + it("parses chain, dtf and proposal id", () => { + expect( + parseProposalUrl("https://app.reserve.org/bsc/index-dtf/cmc20/governance/proposal/6159906300201580929499"), + ).toEqual({ chainId: 56, dtf: "cmc20", proposalId: "6159906300201580929499" }); + }); + + it("rejects anything that is not a proposal url", () => { + expect(() => parseProposalUrl("https://app.reserve.org/bsc/index-dtf/cmc20")).toThrow(/not a governance proposal/); + expect(() => parseProposalUrl("https://app.reserve.org/solana/index-dtf/x/governance/proposal/1")).toThrow(); + }); +}); + +describe("recoverTokenInputs", () => { + it("recovers the price and price error from the encoded band", () => { + const recovered = recoverTokenInputs(shibToken, 18); + + expect(recovered.price).toBeCloseTo(5.032e-6, 12); + expect(recovered.priceError).toBeCloseTo(0.75, 6); + expect(recovered.priceLow).toBeLessThan(recovered.price); + expect(recovered.priceHigh).toBeGreaterThan(recovered.price); + }); +}); + +describe("ordersOfMagnitude", () => { + it("is zero for equal values and 1 for a 10x", () => { + expect(ordersOfMagnitude(5, 5)).toBe(0); + expect(ordersOfMagnitude(50, 5)).toBeCloseTo(1); + expect(ordersOfMagnitude(5, 50)).toBeCloseTo(1); + }); + + it("treats non-positive values as infinitely far apart", () => { + expect(ordersOfMagnitude(0, 5)).toBe(Number.POSITIVE_INFINITY); + }); +}); + +describe("bandUsage", () => { + it("is zero at the mean and one at each edge", () => { + expect(bandUsage(10, 5, 20, 10)).toBe(0); + expect(bandUsage(20, 5, 20, 10)).toBe(1); + expect(bandUsage(5, 5, 20, 10)).toBe(1); + expect(bandUsage(15, 5, 20, 10)).toBeCloseTo(0.5); + }); +}); + +describe("selectDeepestPool", () => { + const token = "0x2859e4544C4bB03966803b044A93563Bd2D0DD4D" as const; + const pair = (liquidityUsd: number, priceUsd: string, chainId = "bsc") => ({ + chainId, + dexId: "pancakeswap", + baseToken: { address: token, symbol: "SHIB" }, + quoteToken: { symbol: "WBNB" }, + priceUsd, + liquidity: { usd: liquidityUsd }, + }); + + it("prices from the deepest pool and sums liquidity across pools on the chain", () => { + const quote = selectDeepestPool([pair(100, "0.9"), pair(300, "1.0"), pair(500, "2.0", "base")], 56, token); + + expect(quote?.price).toBe(1); + expect(quote?.liquidityUsd).toBe(400); + }); + + it("ignores pools where the token is the quote side", () => { + const quoted = { ...pair(900, "1.5"), baseToken: { address: "0x0000000000000000000000000000000000000001" } }; + + expect(selectDeepestPool([quoted], 56, token)).toBeUndefined(); + }); +}); + +describe("symbolMatchesListing", () => { + it("accepts bridged wrappers of the same underlying", () => { + expect(symbolMatchesListing("BTCB", "btc")).toBe(true); + expect(symbolMatchesListing("SHIB", "shib")).toBe(true); + expect(symbolMatchesListing("SHIB", "shibb")).toBe(false); + }); +}); + +describe("Report", () => { + it("only counts disaster-pass failures as blocking", () => { + const report = new Report(); + report.record("outcomes", "fail", "liquidity route failed"); + expect(report.failures).toBe(0); + + report.record("disasters", "fail", "pool price outside band"); + expect(report.failures).toBe(1); + }); +}); diff --git a/packages/tooling/tsconfig.json b/packages/tooling/tsconfig.json new file mode 100644 index 0000000..8cfde14 --- /dev/null +++ b/packages/tooling/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@dtf-interface/tsconfig/config.json", + "compilerOptions": { + "types": ["node"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/tooling/vitest.config.ts b/packages/tooling/vitest.config.ts new file mode 100644 index 0000000..fc87601 --- /dev/null +++ b/packages/tooling/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + environment: "node", + globals: true, + }, + resolve: { + alias: [{ find: "@", replacement: path.resolve(dirname, "src") }], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 576442b..ac3a523 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,31 @@ importers: specifier: 'catalog:' version: 4.1.5(@types/node@24.12.3)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@24.12.3)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)) + packages/tooling: + dependencies: + '@reserve-protocol/sdk': + specifier: workspace:* + version: link:../sdk + viem: + specifier: 'catalog:' + version: 2.48.11(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)(zod@4.4.2) + devDependencies: + '@dtf-interface/tsconfig': + specifier: workspace:* + version: link:../../tooling/tsconfig + '@types/node': + specifier: ^24.12.2 + version: 24.12.3 + tsx: + specifier: ^4.20.6 + version: 4.21.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@types/node@24.12.3)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@24.12.3)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.48.0)(tsx@4.21.0)(yaml@2.8.3)) + tooling/tsconfig: {} packages: