diff --git a/.agents/shared/metrics/hits.jsonl b/.agents/shared/metrics/hits.jsonl index d866af4a3a..3cb3979bff 100644 --- a/.agents/shared/metrics/hits.jsonl +++ b/.agents/shared/metrics/hits.jsonl @@ -24,3 +24,6 @@ {"ts":"2026-06-10T10:36:11.062Z","tool":"Edit","file":"src/modules/finance/dialogs/assetSelectionDialog/index.ts","rule":"dialog-conventions","bytes":3499,"elapsed_ms":2,"adapter":"generic"} {"ts":"2026-06-10T10:36:11.106Z","tool":"Edit","file":"src/modules/finance/constants/financeDialogsDefinitions.ts","rule":"dialog-conventions","bytes":3499,"elapsed_ms":1,"adapter":"generic"} {"ts":"2026-06-10T10:36:11.149Z","tool":"Edit","file":"src/plugins/tokenPlugin/index.ts","rule":"plugin-slot-registration","bytes":3795,"elapsed_ms":1,"adapter":"generic"} +{"ts":"2026-06-02T20:55:13.382Z","tool":"Edit","file":"src/shared/api/flowIndexer/flowIndexerTypes.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-06-02T20:55:24.785Z","tool":"Edit","file":"src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-06-03T09:00:54.071Z","tool":"Edit","file":"src/shared/api/flowIndexer/index.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} diff --git a/.gitignore b/.gitignore index 2fbf822007..1c46b4b91b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ # local anvil fork state anvil-* +# LMM_DEMO_HACK: local-only symlink to dao-launchpad/lido/script/demo/manifest.json +/public/lmm-manifest.json + # IDE .cursor diff --git a/biome.json b/biome.json index ee23f3fefd..18b9dc01e0 100644 --- a/biome.json +++ b/biome.json @@ -377,6 +377,66 @@ } } } + }, + { + "includes": [ + "src/modules/flow/components/lidoMoneyMachine/**", + "src/shared/lidoPreview/**" + ], + "linter": { + "rules": { + "a11y": { + "useKeyWithClickEvents": "off", + "useSemanticElements": "off", + "useAriaPropsForRole": "off", + "noNoninteractiveElementInteractions": "off", + "noStaticElementInteractions": "off" + }, + "correctness": { + "noUnusedVariables": "warn", + "useSingleJsDocAsterisk": "off", + "useExhaustiveDependencies": "warn" + }, + "style": { + "useConsistentTypeDefinitions": "off", + "useBlockStatements": "off", + "useTemplate": "off", + "noRestrictedImports": "off" + }, + "suspicious": { + "useAwait": "off", + "noConsole": "off" + }, + "complexity": { + "useOptionalChain": "off" + } + } + }, + "css": { + "linter": { + "enabled": false + } + }, + "assist": { + "actions": { + "source": { + "useSortedAttributes": "off", + "organizeImports": "off" + } + } + } + }, + { + "includes": [ + "src/shared/lidoPreview/**" + ], + "linter": { + "rules": { + "style": { + "useDefaultSwitchClause": "off" + } + } + } } ], "assist": { diff --git a/config/.env.local b/config/.env.local index bfc9d9074e..6a60151361 100644 --- a/config/.env.local +++ b/config/.env.local @@ -15,3 +15,23 @@ NEXT_PUBLIC_API_VERSION=v3 # Advanced governance cutoff timestamp: 2026-04-02 00:00:01 UTC NEXT_PUBLIC_GOVERNANCE_ADVANCED_CUTOFF_TIMESTAMP=1775088001 + +# Capital flow indexer (Envio) — POC +NEXT_PUBLIC_FLOW_USE_ENVIO=true +NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=http://localhost:8080/v1/graphql + +# --- Lido Money Machine demo (LMM_DEMO_HACK) --------------------------------- +# Toggle ('1' = on, anything else = off) for the LMM demo override layer. +# When on, the /flow dashboard for the manifest's DAO is served from the local +# Envio + manifest pair instead of the Aragon backend. Production builds MUST +# leave this unset. +NEXT_PUBLIC_LMM_DEMO_MODE=1 +# URL the front-end fetches the demo manifest from. Local default points at +# `public/lmm-manifest.json`; on the VM use `https:///manifest.json`. +NEXT_PUBLIC_LMM_MANIFEST_URL=/lmm-manifest.json +# Envio Hasura GraphQL endpoint indexing the demo fork. Local default is the +# `pnpm envio dev` server; on the VM use `https:///graphql`. +NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=http://localhost:8080/v1/graphql +# Anvil RPC the demo writes to. Local default is `pnpm anvil`; on the VM use +# `https:///rpc`. Must be in `LMM_RPC_ALLOWLIST` in lmmDemoConfig.ts. +NEXT_PUBLIC_LMM_RPC_URL=http://localhost:8545 diff --git a/config/.env.preview b/config/.env.preview index c8c64da2b5..b08dca036d 100644 --- a/config/.env.preview +++ b/config/.env.preview @@ -15,3 +15,10 @@ NEXT_PUBLIC_API_VERSION=v3 # Advanced governance cutoff timestamp: 2026-04-02 00:00:01 UTC NEXT_PUBLIC_GOVERNANCE_ADVANCED_CUTOFF_TIMESTAMP=1775088001 + +# --- Lido Money Machine demo (LMM_DEMO_HACK) --------------------------------- +NEXT_PUBLIC_LMM_DEMO_MODE=1 +NEXT_PUBLIC_LMM_MANIFEST_URL=https://tests.aragon.in/manifest.json +NEXT_PUBLIC_LMM_RPC_URL=https://tests.aragon.in/rpc +NEXT_PUBLIC_FLOW_USE_ENVIO=true +NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=https://tests.aragon.in/graphql diff --git a/docs/lido-mmd-production-readiness.md b/docs/lido-mmd-production-readiness.md new file mode 100644 index 0000000000..2b5da294ed --- /dev/null +++ b/docs/lido-mmd-production-readiness.md @@ -0,0 +1,101 @@ +# Lido Money Machine — production readiness checklist + +The MVP shipped on `money-mchine-dashboard` (note: branch name has a typo +which is preserved for git history continuity) — and any branch that includes +`infra/lmm-demo/`, `src/modules/flow/demo/`, `src/modules/flow/components/lidoMoneyMachine/`, +or `src/shared/lidoPreview/`) is **demo-only**. This document is the +delta between the demo branch and a real production rollout that talks to +the Aragon-hosted Envio + the live mainnet Capital Router primitives. + +Every line item below maps back to a `LMM_DEMO_HACK` annotation in the +source — grep for them once you start the migration: + +```bash +rg LMM_DEMO_HACK app/src capital-flow-indexer/src +``` + +## P0 — required before any production demo + +| Area | MVP | Production | +| ----------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **Data source** | Local Envio at `localhost:8080` / VM Hasura behind host nginx + Cloudflare | Aragon-hosted Envio (single endpoint for all flow data). | +| **DAO query override** | `useDao`, `useDaoByEns`, `useDaoPolicies`, `useDaoPermissions` short-circuit | Remove `tryLmmDao*Override()` calls; the Aragon Backend Service must return the LMM DAO + dispatcher policy. | +| **Synthetic policy** | `buildPoliciesFromManifest()` synthesizes one `IDaoPolicy` | Backend service emits the real policy + sub-strategies; mapper consumes them like any other multi-dispatch. | +| **Dispatch tx flow** | `LmmDemoDispatchDialog` writes via viem against Anvil | `ProductionDispatchDialog` (wagmi `useSendTransaction`) — keep the routing branch but force it to the prod path. | +| **Manifest** | `public/lmm-manifest.json` or `https:///manifest.json` | Drop entirely — addresses come from the backend service or from the chain itself via `inspect()`. | +| **Cheats menu** | `LmmCheatsMenu` (warp time, top up balances, etc) | Remove. The production app must never expose these affordances. | +| **Topology view** | `LmmPolicyTopology` runs `inspect()` from the browser | Same component is fine for production *if* the RPC is configured per-network; otherwise pre-compute server-side. | +| **Demo banner / safety guards** | `LmmDemoBanner`, `assertForkRpc`, `LMM_RPC_ALLOWLIST` | Remove `LmmDemoBanner` mounts. Keep `assertForkRpc` only behind the demo flag. | + +## P1 — required before the live indexer rollout + +| Area | MVP | Production | +| ----------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| **Indexer event handlers** | Embedded-strategy registration in `capital-flow-indexer` (this fork) | Port the new event handlers (`DAOFactory`, `DAO`, `DispatcherPlugin`, `WrapDispatchStrategy`, `UniV2LiquidityDispatchStrategy`, `GatedCowSwapDispatchStrategy`, `StreamUntilBudget`, `FullBudget`, `PriceFloorGate`, `EpochProvider`, swap orders) into Aragon's hosted indexer. | +| **Schema** | `Strategy`, `Budget`, `Gate`, `EpochProvider`, `SwapOrder` types | Same schema — apply as a migration on the hosted indexer. | +| **ABI shipping** | `capital-flow-indexer/abis/*.json` shipped locally | Move to a shared `@aragon/contracts-abi` package or fetch from a versioned releases endpoint. | +| **Action decoder** | New kinds: `wrap`, `univ2AddLiquidity`, `swapPresign`, `approve` | Keep — they're production-shaped (no Anvil-specific code). | +| **Plugin repo whitelist** | Demo plugin IDs hardcoded in `constants.ts` | Replace with the real repository addresses once `OSX-378` ships and the plugins are deployed. | + +## P2 — UI polish before the public preview + +| Area | MVP | Production | +| ----------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| **Multi-dispatch chain rendering** | Embedded `Strategy[]` chips with budget/gate/epoch sub-chips | Keep — applies to any dispatcher, demo or production. | +| **Swap chart markers** | Cyan paired markers + skip markers in `FlowPolicyChart` | Keep — production benefit, not demo-specific. | +| **`FlowPolicyTree` MULTI_DISPATCH** | Renders `LmmPolicyTopology` for the demo DAO; SVG tree elsewhere | Render `LmmPolicyTopology` for every multi-dispatch policy once preview-lib supports prod naming. | +| **Recipients aggregate** | Reads `RecipientAggregate` from envio | Keep — already production-shaped. | +| **Activity feed** | Reads `PolicyExecution`/`PolicyEvent` | Keep. | +| **Effective `now` clock** | `useFlowNow()` returns `chainNowMs` (Anvil) when demo is on | `useFlowNow()` becomes a no-op (returns `Date.now()`); `chain-now` hack drops out of the bundle. | +| **Orchestrator live snapshot** | `useLmmLiveSnapshot()` polls Anvil to fill `liveSnapshot` | Indexer emits `OrchestratorSnapshot`; `FlowDataProvider` reads it from the regular query. | +| **Destinations table** | `flowRecipientsTable` falls back to manifest-derived rows | Indexer emits `recipient` on `ExecutionTransfer`; the table merges them automatically. | +| **Money-flow graph** | `buildMoneyFlowGraph()` derives edges from in-browser `simulate()` | Indexer materialises `MoneyFlowEdge` (or directional `ExecutionTransfer`); React Flow renders that. | +| **Live state Card** | `StatusView` reads `liveSnapshot` directly | Bind `StatusView` props to the indexed snapshot; component itself stays. | + +## Cleanup checklist (one-shot) + +When the production setup is ready, the following deletions complete the +migration: + +1. `app/src/modules/flow/demo/` — entire directory (config, override, + safety, banner). +2. `app/src/modules/flow/components/lidoMoneyMachine/` — entire directory + (vendored UI). +3. `app/src/shared/lidoPreview/` — entire directory (vendored library). + * If `LmmPolicyTopology` was extended to non-demo policies in P2, + keep it but rename + move out of `lidoMoneyMachine/`. +4. `app/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx`. +5. `app/infra/lmm-demo/` — entire directory. +6. `NEXT_PUBLIC_LMM_*` env vars from Vercel preview + every `.env*`. +7. Re-route `DispatchTransactionDialog` to not import `LmmDemoDispatchDialog`. +8. Remove `LmmDemoBanner` mounts from `flowOverviewPageClient.tsx` and + `flowPolicyDetailPageClient.tsx`. +9. Drop `useIsLmmDemoDao` import from `dispatchTransactionDialog.tsx` and + `lmmDaoOverride` from each `useDao*` hook. +10. Re-run typecheck + lint and a smoke test against an Aragon production + DAO to confirm nothing references the deleted modules. + +## Acceptance gates + +* `pnpm test --filter app -- --testPathPattern 'flow|capitalFlow'` green. +* `pnpm lint --filter app` green. +* `pnpm typecheck --filter app` green. +* Manual smoke against a production DAO: `/flow` overview loads, no + console errors, no `LMM_DEMO_*` references in network or runtime logs. +* Manual smoke against the demo branch on a preview URL: dispatcher + policy detail page shows topology + cheats menu + demo banner. + +## Risk log + +* **Vendored components depend on `@xyflow/react` + `@dagrejs/dagre`** — + these dependencies now ship in production bundles even when the demo + flag is off. Consider tree-shaking via a dynamic import for + `LmmPolicyTopology` (already a client-only component). +* **`viem` direct RPC use in `actions.ts`** — never imported from + production code paths today, but a future refactor that lifts + `dispatchAction` into a shared helper would inadvertently ship a + hardcoded Anvil deployer private key. The constant is annotated; the + removal step above keeps it isolated. +* **`assertForkRpc` allowlist** — must be reviewed every time the demo + VM is rehosted. Forgetting to update it surfaces as a hard error in + the dialog, not as a silent fall-through to a real chain. diff --git a/docs/lido-mmd-status.md b/docs/lido-mmd-status.md new file mode 100644 index 0000000000..363f706340 --- /dev/null +++ b/docs/lido-mmd-status.md @@ -0,0 +1,78 @@ +# Lido MMD demo — live status + +> Living doc for the `money-mchine-dashboard` branch (note: typo in the +> branch name is preserved deliberately). Update the table +> below whenever a chunk lands so parallel agents (UI polish, indexer +> follow-ups, infra) can grab the next unlocked task without re-reading +> the whole plan. + +## Branches involved + +| Repo | Branch | Purpose | +| -------------------------- | ---------------------------- | --------------------------------------------- | +| `aragon/app` | `money-mchine-dashboard` | UI + demo override layer (note: branch name has a typo, kept for git history continuity) | +| `aragon/capital-flow-indexer` | branch in this workspace | Envio handlers + schema for the new CR primitives | +| `aragon/dao-launchpad` | `f/lido-demo` | Forge/Anvil deployment, preview lib + UI (vendored) | + +## Status + +| Track / task | Owner agent | State | Notes | +| ---------------------------------------------------------------------------------------------------- | ----------- | ------------- | ----- | +| VM stack: docker-compose (anvil persistent, envio + postgres + hasura), nginx.lmm-demo.conf snippet for host nginx, init-demo.sh, vm-README.md | demo-infra | **done** | `infra/lmm-demo/vm/`. Persistent anvil via `--state`. Cloudflare Flexible-SSL fronts host nginx (no TLS on VM); nginx alias serves manifest from `/srv/lmm/`. | +| `capital-flow-indexer` ABIs | indexer | **done** | DAOFactory / DAO / PSP-InstallationApplied + Dispatcher/Strategies/Budgets/Gate/Epoch/CowSwap/MockOracle. | +| `capital-flow-indexer` schema | indexer | **done** | `Strategy`, `Budget`, `Gate`, `EpochProvider`, `SwapOrder` types; `PolicyExecution.strategyIndex/strategy/skipped/skippedReason`. | +| `capital-flow-indexer` EventHandlers | indexer | **done** | DAOCreated/MetadataSet/InstallationApplied/DispatchHandled/StrategyFailed/CowSwapOrderPosted/PriceFloorGate.*/StreamUntilBudget.*/EpochProvider.*. | +| `capital-flow-indexer` actionDecoder | indexer | **done** | `wrap`, `addLiquidity`, `setPreSignature`, `approve` decoded; internal kinds filtered out of outbound transfers. | +| Vendored preview-lib (`app/src/shared/lidoPreview/`) | demo-ui | **done** | Source: `dao-launchpad/lido/preview/lib/src`. Refresh procedure in `infra/lmm-demo/README.md`. | +| Vendored preview-ui (`app/src/modules/flow/components/lidoMoneyMachine/`) | demo-ui | **done** | `TopologyView`, `NodeDetails`, `StatusPanel`, `DispatchDialog`, `ActionsMenu`, `useStatus`, `actions`, etc. | +| App deps `@xyflow/react` + `@dagrejs/dagre` | demo-ui | **done** | Added via `pnpm add`. | +| `lmmDemoConfig.ts` — flag, manifest hook, RPC consts | demo-ui | **done** | `NEXT_PUBLIC_LMM_DEMO_MODE`, `useLmmManifest`, `LMM_RPC_ALLOWLIST`. | +| `useDao*` override hooks | demo-ui | **done** | `tryLmmDao*Override()` short-circuits Aragon backend when DAO matches manifest. | +| `flowPolicyTree.tsx` MULTI_DISPATCH → `LmmPolicyTopology` | demo-ui | **done** | Production policies still render the SVG tree. | +| `flowPolicyChart.tsx` — paired swap markers + skipped marker + legend | demo-ui | **done** | New colors in `flowEventStyles.ts`. | +| `flowMultiDispatchCard.tsx` — embedded `Strategy[]` chips with Budget/Gate/Epoch sub-chips | demo-ui | **done** | Falls back to legacy chain for non-LMM orchestrators. | +| `envioFlowMapper.ts` — Strategy/Budget/SwapOrder, cooldown from StreamUntilBudget, embedded-chain | demo-ui | **done** | New `IFlowEmbeddedStrategy[]` on `IFlowOrchestrator`. | +| `useEnvioFlowData.ts` — GraphQL fragments for new entities | demo-ui | **done** | `EXECUTION_FIELDS` fragment + `strategies`, `budget`, `gate`, `epochProvider`, `swapOrders`. | +| `DispatchTransactionDialog` → `LmmDemoDispatchDialog` for demo DAOs | demo-ui | **done** | Routes via `useIsLmmDemoDao(policy.daoAddress)`; production path unchanged. | +| Policy page header — `LmmCheatsMenu` + `LmmDemoBanner` | demo-ui | **done** | `LMM_DEMO_MODE` gate; menu wraps `warpAction`/`topUpStEth`/etc. | +| Safety guards — `assertForkRpc`, `manifestFingerprintCheck`, `LmmDemoBanner` | demo-ui | **done** | `safety.ts` exports; banner mounted on overview + policy pages. | +| `infra/lmm-demo/README.md` — local-first runbook | demo-infra | **done** | Includes manifest symlink trick + cheats walkthrough. | +| `docs/lido-mmd-production-readiness.md` — migration checklist | demo-infra | **done** | One-shot deletion list at the bottom. | +| Vercel preview env vars | demo-infra | **done** | Example values in `infra/lmm-demo/vercel.env.example`; deployer needs to paste them into the Vercel preview-branch env. | +| E2E smoke + production-DAO regression | qa | **done** | `pnpm test` → 1620/1620 passing; `pnpm lint:check` → 0 errors, 3 vendored-code warnings; new `flow/demo/safety.test.ts` covers the RPC allowlist + fingerprint check. Manual rehearsal still recommended once the VM is up (see runbook in `infra/lmm-demo/README.md`). | + +## Dashboard polish hacks (Phase 1–7) + +Every workaround landed by the "LMM demo dashboard polish" pass is tagged +`LMM_DEMO_HACK: ` in code so the prod cleanup `grep` stays cheap. +The table below maps each slug to where it lives, what it does in the +demo, and what should replace it in production. + +| Slug | Where | What it does in demo | Prod replacement | Priority | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -------- | +| `live-snapshot-rpc` | `src/modules/flow/demo/useLmmLiveSnapshot.ts`, `flowDataProvider.tsx` | Polls Anvil directly via `inspect()`+`useStatus`; exposes legs/budgets/gate via `FlowDataContext.liveSnapshot` | Emit an `OrchestratorSnapshot` entity from `capital-flow-indexer` and consume it through the regular flow query | high | +| `pending-from-live` | `src/modules/flow/providers/flowSelectors.ts` (`selectPolicyPending`) | Merges `pending.amount` from `liveSnapshot.budget()` reads into the selector output | Same entity as above; selector falls back to indexer-computed pending | high | +| `chain-now` | `src/modules/flow/demo/useLmmChainNow.ts`, `flowDataProvider.tsx`, `useFlowNow` | Replaces `Date.now()` with the fork's latest-block timestamp so cooldown pills/chart match Anvil | No-op outside demo (`useFlowNow` already returns `Date.now()` when `chainNowMs` is null) | low | +| `destinations-from-manifest` | `src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx` | Synthesises destination rows (DAO, LP recipient, CowSwap settlement, LDO buyback) from `lmm-manifest.json` | Emit a `recipient` (or `flowDirection`) column on `ExecutionTransfer` so the table can read destinations from the indexer | medium | +| `synthetic-policy-installed` | `src/modules/flow/utils/envioFlowMapper.ts` (`mapPolicy`) | Always injects an `INSTALLED` event so the chart shows the marker even if the handler missed it | Indexer guarantees a `PolicyInstalled` event per policy; remove the synthetic event | low | +| `money-flow-from-simulate` | `src/modules/flow/components/lidoMoneyMachine/buildMoneyFlowGraph.ts`, `TopologyView.tsx` | Builds the "Money flow" graph from the in-browser `simulate()` predictor instead of indexed transfers | Materialise `MoneyFlowEdge` (or `ExecutionTransfer` w/ direction) and render from the GraphQL response | medium | + +## How to claim a task + +1. Read the row above + the related `LMM_DEMO_HACK` comments in code. +2. Update the `State` cell to `claimed by ` before editing. +3. Mark `done` when the change merges into `money-mchine-dashboard`. +4. If the task spawns follow-ups, add them as new rows so others can + pick them up. + +## Open questions + +* Should `LmmPolicyTopology` be lazy-loaded (`dynamic()`) when demo mode + is off? It currently pulls in `@xyflow/react` + `@dagrejs/dagre` into + the production bundle even though it never renders. See the risk log + in production-readiness.md. +* Do we want a "Skipped reason" badge on dispatcher cards (separate from + the chart timeline marker)? Today the reason is only visible on the + detail chart tooltip. +* Manifest fingerprint check is wired but no UI surface yet — should it + block writes or only warn? Right now it's informational. diff --git a/infra/lmm-demo/DEMO-SCRIPT.md b/infra/lmm-demo/DEMO-SCRIPT.md new file mode 100644 index 0000000000..31abf79b29 --- /dev/null +++ b/infra/lmm-demo/DEMO-SCRIPT.md @@ -0,0 +1,117 @@ +# Flow Workbench — demo script ("what to show, where") + +A presenter's walkthrough for the **Lido Money Machine** flow on the Anvil +fork. This is the *narrative* layer; for environment setup (Anvil + Envio + +app, manifest symlink, env flags) see [`README.md`](./README.md). + +> All interactive controls live in the **Demo ▾** dropdown in the canvas +> header (visible only when `NEXT_PUBLIC_LMM_DEMO_MODE=1`). The dropdown is +> grouped in the exact order you click through below. + +--- + +## 0 · Pre-flight (once) + +1. Stack up: `just anvil` → `just demo-up` → indexer `pnpm dev` (:8080) → app + `pnpm dev`. Manifest is auto-symlinked. +2. Open `‹app›/dao/ethereum-mainnet/‹manifest.lmm.dao›/flow/workbench`. +3. The topbar (next to **Connect**) has two icon switches: + - **Dashboard ⇄ Canvas** — the two product visions. + - **Workbench ⇄ Focus** — two canvas layouts (same data, different display). + Both are URL-driven (`?view=`), so any state you reach is shareable. + +The page reads **indexed provenance** (Envio `FlowStep`/`FlowEdge`/`SwapFill`) +for amounts and the **live RPC snapshot** for current budget/gate/epoch +readings + the "next dispatch" simulation. Numbers on the canvas are real +on-chain values, never fabricated. + +--- + +## 1 · The flow, in one breath + +Point at the **"What this flow does"** card (top-left on the canvas): + +> The Lido Money Machine automates the DAO treasury through three chained +> strategies — it **wraps stETH → wstETH**, **provides Uniswap V2 liquidity**, +> then runs a **price-gated CoW buyback** of LDO — with all proceeds returning +> to the vault. + +Then trace it left→right on the canvas: **Vault → Wrap → {UniV2 Liquidity, +Gated CoW swap} → Treasury**. Each strategy node hangs its **budget / gate / +epoch** sub-nodes beneath it (the "parts" of the primitive). + +--- + +## 2 · Choreography — state ⇄ action ⇄ what to point at + +Run these top-to-bottom; each row maps a **Demo** action to the visual it +produces. + +| # | Demo action | What changes on the canvas | Talking point | +|---|-------------|----------------------------|----------------| +| **Fund** | `Top up 100 stETH`, `Top up 1000 LDO` | The **Vault** node's balances update live (stETH/LDO rise). | "The treasury is the source; everything the machine moves comes from here." | +| **Open gate** | `Open gate` ($3,200 > $3,000 floor) | The **Price-Floor Gate** sub-node turns **green / open**. | "The buyback only runs when ETH is above the floor — no selling into weakness." | +| **Dispatch** | `Dispatch now` | Wrap node goes **firing**, edges animate with real amounts (stETH→wstETH), UniV2 + CoW fire; the **CoW buy edge shows `~estimated` / pending** (order posted, not yet filled). | "One call fans out to every fireable leg. Wrap output is on-chain-real; the CoW buy is an oracle estimate until it settles." | +| **Settle buyback** | `Settle CoW order` | The CoW node's output edge **solidifies to real** (`100 LDO`, provenance ONCHAIN_EVENT); pending badge clears. | "Now the fill is on-chain — the estimate is replaced by the settled amount." | +| **Close gate** | `Close gate` ($2,800 < floor) | The Gate sub-node turns **red / closed**; open **Simulate** → CoW leg reads **skipped · gate closed**; the CoW node renders **blocked**. | "Drop the price below the floor and the buyback refuses to fire — the gate protects the treasury." | +| **Advance time** | `Skip an epoch` / `Warp +1 day` | The **epoch** counter on the Epoch-Provider sub-node increments; **Next run** in the header updates. | "Budgets stream per epoch; warping the clock shows the next window opening." | + +> **Inspector**: click any node to open the right-rail Inspector — it shows the +> per-node budget / gate / epoch readings and the output destination. Click the +> **Treasury** terminal to show proceeds loop back to the vault. + +--- + +## 3 · The two views worth showing + +- **Workbench** (default): structured — header KPIs, hero canvas, right rail + (This flow stats + **Token throughput** + Inspector + Legend), bottom + dispatch-history strip. Best for a "control room" read. +- **Focus**: full-bleed canvas with floating glass panels. Same data, more + cinematic. Toggle live with the topbar **Workbench ⇄ Focus** switch — the + graph state is preserved across the switch. + +Both surface the **Token throughput** panel — the cumulative per-token tally +(e.g. *stETH out 100 · LDO back 200 · wstETH back 80.9 · UNI-V2 LP*) answering +"how much did we spend, how much did we buy back". + +--- + +## 4 · Replay (history) + +Open the **Dispatch history** strip (bottom in Workbench, bottom-left tab in +Focus). Click any past run → the canvas **re-graphs to that run** with its real +settled amounts and a "Replaying dispatch #N" banner; **Back to live** returns +to the overview. This proves the canvas projects indexed provenance, not a +mock. + +> Note: budget/gate/epoch *readings* are live-only, so in a replay the +> sub-nodes show the run's static config (floor, epoch length) rather than a +> historical reading — the **edge amounts** are the settled, real values. + +--- + +## 5 · Gotchas (so the demo doesn't lie) + +- **Wrap shows `0 stETH`?** The vault was drained by a previous dispatch. Click + `Top up 100 stETH` *before* `Dispatch now` to show a non-zero wrap. (This is + honest accounting — the machine wrapped what was there.) +- **"Dispatch now" is disabled.** The button is gated on the live simulation: + if every leg would be skipped (e.g. same epoch already burned, or gate + closed with nothing else to do), it disables. `Warp +1 day` or `Top up` to + re-arm it. +- **UNI-V2 LP shows `pending` / ghost.** LP minted amount is opaque until the + pair `Mint` event is indexed (a known deferred indexer item) — the dotted + edge + "pending" is the correct, honest rendering, not a bug. +- **Nothing updates after an action.** The live snapshot polls every few + seconds; give it a beat. Confirm Anvil (:8545) and Hasura (:8080) are up. + +--- + +## 6 · CLI equivalents (no UI) + +Every Demo action has a `just` recipe in `dao-launchpad/lido/script/demo/` +(run from `dao-launchpad/lido/`): `just demo-topup`, `just demo-eth-price`, +`just demo-dispatch`, `just demo-settle-cowswap`, `just demo-set-target-epoch`, +`just demo-warp`, `just demo-status`. Useful for scripting a clean state before +a presentation, or driving the fork while the UI is on a projector. diff --git a/infra/lmm-demo/README.md b/infra/lmm-demo/README.md new file mode 100644 index 0000000000..8d994301c2 --- /dev/null +++ b/infra/lmm-demo/README.md @@ -0,0 +1,314 @@ +# Lido Money Machine demo — local-first runbook + +This directory ships the **demo-only** plumbing required to render Jordi's +Lido Money Machine setup inside the Aragon app's `/flow` dashboard. Every +file here is annotated with `LMM_DEMO_HACK`: nothing here ships to +production, the goal is to make a leads/Lido presentation feel like the +production app already does this. + +Two deployment shapes are supported: + +* **Local-first** — everything runs on the presenter's laptop. This is + what you'll use to iterate on the UI without touching the VM. See + [§ Local quickstart](#local-quickstart). +* **VM** — a single host runs Anvil + Envio + Hasura behind the host's + nginx, fronted by Cloudflare in Flexible-SSL mode (TLS terminates at + Cloudflare's edge; the origin only speaks plain HTTP on :80). The + current public endpoint is `https://tests.aragon.in/{rpc,graphql,manifest.json}`. + See [`vm/vm-README.md`](./vm/vm-README.md) for the full decision tree + (when the VM is actually needed) and the setup steps, and + [`vm/nginx.lmm-demo.conf`](./vm/nginx.lmm-demo.conf) for the drop-in + routing snippet. + +Both shapes share the same data model: a manifest JSON describing the +demo DAO + its plugins, plus an Envio GraphQL endpoint indexed against the +same chain. + +And there are two surfaces the demo can render to: + +* **Aragon app `/flow` page** — the full Next.js dashboard with + `LMM_DEMO_MODE=1`. Production-shaped UX; needs Anvil + Envio + the + app dev server (4 terminals). This is what the TL;DR + Local + quickstart below set up. +* **`lido/preview/ui/` (Vite, :5173)** — the standalone UI the + vendored components in `app/src/modules/flow/components/lidoMoneyMachine/` + came from. Needs only Anvil + `just demo-up` + `just ui-dev` (no + Envio, no Aragon app). Useful for quick smoke tests of the + dispatcher topology and cheats menu. See + [§ Optional: standalone preview UI for fast iteration](#optional-standalone-preview-ui-for-fast-iteration). + +--- + +## TL;DR + +```bash +# In dao-launchpad@f/lido-demo (run from the `lido/` subdir, not `lido/preview/`) +cd ../dao-launchpad/lido +just bootstrap # creates .env from .env.example (first time only) +just switch mainnet # selects the mainnet config from just-foundry +$EDITOR .env # set RPC_URL=https://eth-mainnet.g.alchemy.com/v2/ +just anvil # terminal 1: foreground mainnet fork on :8545 +just demo-up # terminal 2: deploys CR slice + LMM DAO; writes + # script/demo/manifest.json + +# In capital-flow-indexer (sibling of app/) +cd ../../capital-flow-indexer +cat > .env <<'EOF' +ENVIO_DEMO_RPC_URL=http://localhost:8545 +EOF +pnpm install # first time only +pnpm dev # terminal 3: indexer + postgres + hasura on :8080 + +# In app/ (already configured via config/.env.local — see Step 3 below). +cd ../app +ln -sf ../../dao-launchpad/lido/script/demo/manifest.json \ + public/lmm-manifest.json +# Flip NEXT_PUBLIC_LMM_DEMO_MODE from 0 to 1 in config/.env.local +# (pnpm dev copies config/.env.local → .env.local on every start via +# scripts/setupEnv.sh local, so editing the root .env.local is a no-op). +pnpm dev # terminal 4: open /dao/ethereum-mainnet//flow +``` + +The DAO address to navigate to is `lmm.dao` from the generated +`manifest.json`. + +--- + +## Local quickstart + +### Prerequisites + +* `pnpm` 11.3.0+ and Node.js 24.13+ (matches `app/package.json` engines + packageManager). +* `bun` 1.3+ (used by `dao-launchpad/lido/preview/`). +* `foundry` (anvil, cast, forge) — `curl -L https://foundry.paradigm.xyz | bash && ~/.foundry/bin/foundryup`. +* `just` — `brew install just` (or `cargo install just`). +* A mainnet **archive** RPC URL for the Anvil fork (Alchemy / Infura / QuickNode). + Public RPCs (`drpc.org`, `llamarpc`) rate-limit the genesis fetch and `just anvil` + will time out. Put it in `dao-launchpad/lido/.env` as `RPC_URL=https://...` + (override of the default in `lib/just-foundry/networks/mainnet.env`). Aragon-internal: + the `ALCHEMY_API_KEY` lives in 1Password — installing the `vars` CLI lets + `just anvil` pick it up automatically. +* The two sibling repos checked out alongside `app/`: + * `dao-launchpad` on branch `f/lido-demo` + * `capital-flow-indexer` (this branch) + +### 1. Start Anvil + deploy the demo + +`just anvil` and `just demo-up` both live in `dao-launchpad/lido/justfile` +(the latter via `import 'script/demo/demo.just'`). `lido/preview/` is a +separate Bun/Vite UI runner and has its own justfile — do **not** run +`demo-up` from there. + +```bash +cd dao-launchpad +git checkout f/lido-demo +cd lido +just bootstrap # first time only — copies .env.example → .env +just switch mainnet # selects mainnet defaults from just-foundry +# Edit .env — at minimum set RPC_URL to a mainnet archive endpoint, e.g. +# RPC_URL=https://eth-mainnet.g.alchemy.com/v2/ + +# Terminal 1 — foreground fork (mainnet, chainId 1, --auto-impersonate): +just anvil + +# Terminal 2 — broadcast PrepareDemo.s.sol against the fork. Idempotent +# (re-running re-deploys everything against a fresh fork; relaunch +# `just anvil` first if you want a clean slate). +just demo-up +``` + +The final step writes `script/demo/manifest.json` with all the addresses +the UI needs. `manifest.lmm.dao` is the address you'll navigate to in +the app. + +#### Optional: standalone preview UI for fast iteration + +If you only want to poke at the dispatcher topology + cheats without +booting the whole Aragon app, the original Vite UI that the +`app/src/modules/flow/components/lidoMoneyMachine/*` files were vendored +from is still alive at `dao-launchpad/lido/preview/ui/`. In a separate +terminal: + +```bash +cd dao-launchpad/lido/preview +just install # first time only (bun install) +just ui-dev # standalone UI on http://localhost:5173 +``` + +This UI reads `../script/demo/manifest.json` directly via Vite, so it +needs `just demo-up` to have run but does **not** need the Envio +indexer or the Aragon app. Use it as a sanity check that the deploy is +healthy before pointing the full app at it. When the vendored code in +`app/` falls out of sync with this UI, see [§ Updating vendored libs](#updating-vendored-libs). + +### 2. Start the indexer + +```bash +cd ../../capital-flow-indexer # from dao-launchpad/lido/, two ups to the workspace root +cat > .env <<'EOF' +# config.yaml's mainnet (chain 1) network reads ENVIO_DEMO_RPC_URL +# with a fallback to http://anvil:8545 (the docker hostname used on the VM). +# Locally we point it at the host anvil. +ENVIO_DEMO_RPC_URL=http://localhost:8545 +EOF +pnpm install # first time only +pnpm dev # serves GraphQL on http://localhost:8080 +``` + +Note: `ENVIO_CHAIN_ID` / `ENVIO_START_BLOCK` are **not** used — they're +hard-coded in `config.yaml` (chain 1, block 0) for the local mainnet +fork. Only `ENVIO_DEMO_RPC_URL` matters. + +The indexer auto-discovers the demo's contracts via `DAOFactory.DAOCreated` +and `PluginSetupProcessor.InstallationApplied` — no need to plug +addresses in by hand. + +### 3. Run the app + +```bash +cd ../app +pnpm install +``` + +The four LMM flags already live in `config/.env.local` (checked in). +**Do not edit the root `.env.local`** — `pnpm dev` runs +`scripts/setupEnv.sh local` first, which copies `config/.env.local` +→ `.env.local` and clobbers any edits. + +Flip the master switch in `config/.env.local`: + +``` +NEXT_PUBLIC_LMM_DEMO_MODE=1 +``` + +The other three flags should already match the local-first defaults: + +``` +NEXT_PUBLIC_LMM_MANIFEST_URL=/lmm-manifest.json +NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=http://localhost:8080/v1/graphql +NEXT_PUBLIC_LMM_RPC_URL=http://localhost:8545 +``` + +Symlink the freshly-generated manifest so the dev server can serve it. +The symlink is `.gitignore`d (`/public/lmm-manifest.json`), so creating +it is local-only: + +```bash +ln -sf ../../dao-launchpad/lido/script/demo/manifest.json \ + public/lmm-manifest.json +``` + +Boot the app: + +```bash +pnpm dev +``` + +Open `/dao/ethereum-mainnet//flow` — you should see the +LMM DAO with the Money Machine dispatcher rendered as the only +multi-dispatch policy. Click into the policy to see the React Flow +topology, the cheats menu, and the demo-mode banner. + +> **Presenting?** Once the stack is up, follow +> [`DEMO-SCRIPT.md`](./DEMO-SCRIPT.md) — a state-by-state "what to show, where" +> walkthrough (fund → gate → dispatch → settle → replay) that mirrors the +> Demo ▾ dropdown ordering. + +### 4. Trigger dispatches + +* **From the UI** — open the policy detail page → "Dispatch now" → the + vendored `DispatchDialog` runs `simulate()` then `dispatch()` against + the Anvil fork (no wallet required, Anvil is in `--auto-impersonate`). +* **From the cheats menu** — same UI, also exposes: + * Warp +1 day / +7 days + * Top up 100 stETH / 1000 LDO from the configured impersonated holders + * Set ETH/USD price (opens/closes the PriceFloorGate) + * Bump StreamUntilBudget target epoch + * Settle the pending CoW order (mock settlement) +* **From the CLI** — `just demo-warp`, `just demo-dispatch`, + `just demo-status`, `just demo-eth-price`, `just demo-topup`, + `just demo-set-target-epoch`, `just demo-settle-cowswap` — all defined + in `dao-launchpad/lido/script/demo/demo.just` (imported by + `dao-launchpad/lido/justfile`). Run them from `dao-launchpad/lido/`. + +--- + +## What the override does + +When `NEXT_PUBLIC_LMM_DEMO_MODE=1` and the URL DAO address matches +`manifest.lmm.dao`: + +| Hook / dialog | Production | Demo mode | +| ------------------------------------------ | --------------------------------- | ---------------------------------------------------- | +| `useDao` / `useDaoByEns` | `aragonBackendService.getDao()` | Envio `Dao` + manifest metadata | +| `useDaoPolicies` | Backend `getDaoPolicies()` | One synthetic `IDaoPolicy` for the dispatcher | +| `useDaoPermissions` | Backend `getDaoPermissions()` | Synthetic admin set so the dispatch button is shown | +| `DispatchTransactionDialog` | wagmi `useSendTransaction()` | Direct viem `writeContract()` against the Anvil RPC | +| `FlowPolicyTree` (MULTI_DISPATCH branch) | SVG tree from REST sub-router IDs | Vendored React Flow topology from preview-lib `inspect()` | +| Policy header / overview | — | Demo banner + cheats menu | + +Everything else (charts, recipients, history, navigation) is the +unchanged production code reading from the Envio indexer. + +## Safety guards + +* `assertForkRpc(rpcUrl)` (`src/modules/flow/demo/safety.ts`) refuses to + fire any write tx when the RPC isn't in `LMM_RPC_ALLOWLIST`. +* The dispatch dialog and cheats menu both run `assertForkRpc()` before + every action. +* The `LmmDemoBanner` is mounted on the overview + policy pages so the + presenter cannot miss the chain context. +* `manifestFingerprintCheck()` compares the manifest's DAO address with + the indexer's first `Dao` entity; UI should refuse demo writes when + they disagree (TODO: surface the result in the banner). + +--- + +## Updating vendored libs + +The app vendors two slices of `dao-launchpad@f/lido-demo`: + +| Source | Destination | +| --------------------------------------- | -------------------------------------------------- | +| `lido/preview/lib/src/**` | `app/src/shared/lidoPreview/` | +| `lido/preview/ui/src/{Topology,Status,Steps,DispatchDialog,ActionsMenu,useStatus,actions,icons,layout,format,styles.css}` | `app/src/modules/flow/components/lidoMoneyMachine/` | + +When Jordi ships an update: + +1. Pull the latest commit of `dao-launchpad@f/lido-demo`. +2. Re-copy the files above on top of the vendored copies. +3. Re-add the `// Vendored from …` header to each file (preserved by the + sed pass in the original vendoring run; manually add for new files). +4. Strip `.ts`/`.tsx` extensions from relative imports (the app uses + `moduleResolution=bundler`): + ```bash + sed -i '' -E 's|(from \"\\.\\.?/[^\"]+)\\.tsx?|\\1|g' \ + src/shared/lidoPreview/*.ts \ + src/modules/flow/components/lidoMoneyMachine/*.{ts,tsx} + ``` +5. Re-run `pnpm typecheck && pnpm lint`. + +--- + +## Troubleshooting + +* **"manifest 404 from /lmm-manifest.json"** — the symlink in `public/` + is missing or `just demo-up` hasn't completed. Re-run step 1. +* **"refusing to use RPC ..."** — the RPC URL is not in + `LMM_RPC_ALLOWLIST` (`lmmDemoConfig.ts`). Add the hostname there. +* **Production DAOs render the demo banner** — `NEXT_PUBLIC_LMM_DEMO_MODE` + is forced on. Set it back to `0` in `config/.env.local` (the source + of truth; the root `.env.local` is regenerated on every `pnpm dev`). +* **My edits to `.env.local` disappeared** — `scripts/setupEnv.sh local` + ran on `pnpm dev` and overwrote it with `config/.env.local`. Edit + `config/.env.local` instead. +* **Anvil falls over on startup / mainnet fork won't sync** — `just anvil` + is using the public `drpc.org` fallback. Put a real archive endpoint + in `dao-launchpad/lido/.env` as `RPC_URL=https://...`. +* **"Topology inspection failed"** — the RPC isn't reachable, or the + manifest's `dispatcher` address doesn't exist on the connected chain. + Verify Anvil is still up: `curl -X POST http://localhost:8545 + -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'`. +* **Dispatch reverts immediately** — likely the PriceFloorGate is closed + or the StreamUntilBudget hasn't accrued enough. Use the cheats menu + to "Set ETH/USD to $3000" + "Warp +1 day" + try again. diff --git a/infra/lmm-demo/vercel.env.example b/infra/lmm-demo/vercel.env.example new file mode 100644 index 0000000000..94f78361fc --- /dev/null +++ b/infra/lmm-demo/vercel.env.example @@ -0,0 +1,23 @@ +# Vercel preview env vars for the Lido Money Machine demo branch. +# Apply via Vercel dashboard → Settings → Environment Variables → "Preview" +# scope only. These MUST NOT be set on the Production scope. +# +# LMM_DEMO_HACK: every variable below is consumed by the demo override +# layer (`src/modules/flow/demo/lmmDemoConfig.ts`). See +# `docs/lido-mmd-production-readiness.md` for the migration checklist. + +# Master switch. When unset (or != '1'), the override layer no-ops and +# production code paths take over. +NEXT_PUBLIC_LMM_DEMO_MODE=1 + +# Manifest URL served by the demo VM (via host nginx fronted by Cloudflare). +# Replace the hostname if the demo moves to a different origin. +NEXT_PUBLIC_LMM_MANIFEST_URL=https://tests.aragon.in/manifest.json + +# Envio Hasura GraphQL endpoint proxied by host nginx (/graphql → :8080/v1/graphql). +NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=https://tests.aragon.in/graphql + +# Anvil RPC proxied by host nginx (/rpc → :8545). The hostname must also +# be present in LMM_RPC_ALLOWLIST in `src/modules/flow/demo/lmmDemoConfig.ts` — +# `assertForkRpc()` blocks writes against any host outside the allowlist. +NEXT_PUBLIC_LMM_RPC_URL=https://tests.aragon.in/rpc diff --git a/infra/lmm-demo/vm/.env.vm.example b/infra/lmm-demo/vm/.env.vm.example new file mode 100644 index 0000000000..44ed9916b6 --- /dev/null +++ b/infra/lmm-demo/vm/.env.vm.example @@ -0,0 +1,38 @@ +# Copy to .env, fill in values, then `docker compose up -d`. +# +# Public TLS (https://tests.aragon.in) is terminated by Cloudflare (Flexible +# mode). CF talks to the origin nginx over plain HTTP :80. Path routing +# (/rpc, /graphql, /manifest.json) is configured in the host nginx — see +# ./nginx.lmm-demo.conf. None of that is configured from this env file; +# the values below only affect the docker-compose stack itself. + +# Mainnet RPC for the anvil fork. Public RPCs rate-limit during the genesis +# fetch — use Alchemy / Infura / Quicknode / your own node. +MAINNET_RPC=https://eth-mainnet.g.alchemy.com/v2/REPLACE_ME + +# Optional: pin the fork to a specific block (improves determinism across +# demos). Leave empty to fork at HEAD on first boot. +FORK_BLOCK= + +# Postgres credentials. These are internal to the docker network; never +# exposed to the public — fine to keep simple. +POSTGRES_PASSWORD=changeme + +# Hasura admin secret. Anonymous queries are allowed via the `public` role +# we configure in compose — this only protects the admin console (which is +# never proxied by host nginx, so reaching it requires SSH + curl on the VM). +HASURA_ADMIN_SECRET=changeme + +# Where init-demo.sh writes manifest.json. Must match the `alias` path in +# nginx.lmm-demo.conf (default /srv/lmm/manifest.json). +MANIFEST_OUTPUT_DIR=/srv/lmm + +# Public hostname only used by init-demo.sh's final log line. Has no +# effect on routing — Cloudflare DNS and the nginx server_name are what +# actually decide which hostname reaches the stack. +PUBLIC_HOSTNAME=tests.aragon.in + +# Override only if the indexer source is mounted at a non-default location +# relative to the compose file. Default expects: +# /capital-flow-indexer/ (sibling of app/) +# HOST_INDEXER_PATH=/srv/capital-flow-indexer diff --git a/infra/lmm-demo/vm/docker-compose.yml b/infra/lmm-demo/vm/docker-compose.yml new file mode 100644 index 0000000000..27699a5129 --- /dev/null +++ b/infra/lmm-demo/vm/docker-compose.yml @@ -0,0 +1,168 @@ +# Lido Money Machine demo — VM stack +# +# Four services: anvil (mainnet fork), postgres + envio + hasura (indexer). +# TLS termination + CORS + path routing happen on the host nginx (configured +# separately — see ./nginx.lmm-demo.conf for a drop-in snippet). Cloudflare +# fronts the host nginx in Flexible mode (TLS terminates at CF edge; CF talks +# to origin :80 over plain HTTP). +# +# Bring up: +# cp .env.vm.example .env +# $EDITOR .env # MAINNET_RPC + POSTGRES_PASSWORD + HASURA_ADMIN_SECRET +# docker compose up -d +# ./init-demo.sh +# +# Tear down: +# docker compose down -v # WARNING: drops anvil state + indexer DB +# +# Daily ops: +# docker compose logs -f anvil +# docker compose exec anvil cast block latest --rpc-url http://localhost:8545 +# curl https://tests.aragon.in/manifest.json | jq . + +name: lmm-demo + +services: + anvil: + image: ghcr.io/foundry-rs/foundry:latest + restart: unless-stopped + # --silent keeps logs sane; --auto-impersonate lets Jordi's scripts sign as any address; + # --state + --state-interval gives us crash-safety (anvil writes state every 30s); + # --chain-id 1 keeps mainnet contract checks happy (Lido stETH/wstETH/Curve/LDO). + # + # Memory safety: when forking mainnet, anvil caches every account/storage slot + # and block it serves, and that cache is unbounded. The real OOM trigger was + # a bad indexer start_block (~60k blocks back, see the envio service note): + # envio replayed tens of thousands of historical blocks through the fork, + # ballooning anvil to >12GB and OOM-killing the host. That is fixed at the + # source (envio now indexes only forkBlock-5 -> tip, a handful of blocks), so + # we deliberately do NOT use --prune-history / --transaction-block-keeper here: + # those drop historical in-memory state/receipts, and envio's handlers do + # eth_calls at the event's block height for token metadata and read receipts + # across the demo's deployment blocks — pruning could make it miss or mis-read + # DAO events. Correctness wins; memory is instead bounded by `mem_limit`, + # which OOM-kills *only* this container (auto-restart) instead of the whole VM. + entrypoint: ["anvil"] + command: + - "--fork-url=${MAINNET_RPC}" + - "--chain-id=1" + - "--auto-impersonate" + - "--host=0.0.0.0" + - "--port=8545" + - "--state=/data/anvil.json" + - "--state-interval=30" + - "--silent" + # Hard cap (host has 15Gi). With the correct start_block anvil sits well under + # this; the cap is a safety net against another runaway, not the expected use. + mem_limit: 6g + mem_reservation: 1g + volumes: + - lmm-data:/data + # Bind to loopback only — host nginx proxies /rpc to here. We never want + # 8545 exposed publicly (UFW already blocks it, but defense in depth). + ports: + - "127.0.0.1:8545:8545" + + postgres: + image: postgres:17-alpine + restart: unless-stopped + mem_limit: 2g + mem_reservation: 256m + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + POSTGRES_DB: envio + volumes: + - lmm-pg:/var/lib/postgresql/data + expose: + - "5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d envio"] + interval: 10s + timeout: 5s + retries: 10 + + envio: + # The capital-flow-indexer image is built locally from the indexer repo, + # mounted in by the host (see HOST_INDEXER_PATH in .env). `envio start` + # connects to postgres + our anvil RPC. See infra/lmm-demo/README.md + # for build instructions. + image: node:22-bookworm-slim + restart: unless-stopped + mem_limit: 4g + mem_reservation: 512m + working_dir: /indexer + depends_on: + postgres: + condition: service_healthy + anvil: + condition: service_started + environment: + ENVIO_PG_HOST: postgres + ENVIO_PG_PORT: "5432" + ENVIO_PG_USER: postgres + ENVIO_PG_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + ENVIO_PG_DATABASE: envio + # Anvil RPC, used by handlers for token metadata eth_calls AND (via + # config.yaml's `rpc.for: sync` directive on chain-id 1) as the primary + # data-source — HyperSync is intentionally bypassed for the LMM fork. + ENVIO_DEMO_RPC_URL: http://anvil:8545 + # NOTE: ENVIO_DEMO_START_BLOCK is intentionally NOT set here. + # + # init-demo.sh pins the real fork-block (manifest.blockNumber - 5) into + # ${MANIFEST_OUTPUT_DIR}/.env.indexer, which is loaded via `env_file` below. + # Docker Compose gives `environment:` HIGHER precedence than `env_file:`, so + # if we also declared ENVIO_DEMO_START_BLOCK here it would SHADOW the pinned + # value — which is exactly what happened: envio fell back to the bootstrap + # default (~25.18M) and replayed ~60k mainnet blocks through the anvil fork, + # OOM-killing the host. Leaving it out lets `.env.indexer` win. The + # first-boot bootstrap default lives in config.yaml as + # `start_block: ${ENVIO_DEMO_START_BLOCK:-25179300}`, used only when + # .env.indexer does not yet exist. + # Envio talks to Hasura's admin /v1/metadata endpoint at boot to + # track tables and grant SELECT to the `public` role so the host + # nginx proxy can expose anonymous reads via /graphql. Without + # these three vars envio defaults to localhost:8080 + secret + # "testing", which fails inside docker. + HASURA_GRAPHQL_ENDPOINT: http://hasura:8080/v1/metadata + HASURA_GRAPHQL_ROLE: admin + HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-changeme} + env_file: + # Optional override file written by init-demo.sh; safe to be missing on + # first boot (compose treats a missing env_file as empty). + - path: ${MANIFEST_OUTPUT_DIR:-/srv/lmm}/.env.indexer + required: false + volumes: + - ${HOST_INDEXER_PATH:-../../../../capital-flow-indexer}:/indexer + command: ["sh", "-c", "corepack enable && cd /indexer && pnpm install --frozen-lockfile && pnpm codegen && pnpm start"] + expose: + - "8080" + + hasura: + image: hasura/graphql-engine:v2.42.0 + restart: unless-stopped + mem_limit: 1g + mem_reservation: 256m + depends_on: + postgres: + condition: service_healthy + environment: + HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-changeme}@postgres:5432/envio + HASURA_GRAPHQL_ENABLE_CONSOLE: "true" + HASURA_GRAPHQL_DEV_MODE: "false" + # Anonymous reads use the `public` role. Mutations / admin console + # require HASURA_GRAPHQL_ADMIN_SECRET, which we never proxy publicly. + HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-changeme} + HASURA_GRAPHQL_UNAUTHORIZED_ROLE: "public" + # Disable Hasura's own CORS middleware — host nginx owns the only + # set of Access-Control-* headers. Without this, Hasura echoes + # `Origin: ...` + `credentials: true` on top of nginx's `*`, and + # browsers reject the response on duplicate/conflicting headers. + HASURA_GRAPHQL_DISABLE_CORS: "true" + # Loopback-only — host nginx proxies /graphql to here. + ports: + - "127.0.0.1:8080:8080" + +volumes: + lmm-data: + lmm-pg: diff --git a/infra/lmm-demo/vm/init-demo.sh b/infra/lmm-demo/vm/init-demo.sh new file mode 100755 index 0000000000..8c4a5be02a --- /dev/null +++ b/infra/lmm-demo/vm/init-demo.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# +# Lido Money Machine demo — one-shot bootstrap. +# +# Idempotent: safe to re-run. Assumes `docker compose up -d` has already +# brought up anvil + postgres + envio + hasura. TLS/CORS/routing is owned +# by the host nginx (see ./nginx.lmm-demo.conf), not by this script. +# +# Steps: +# 1. Wait for anvil to be ready (eth_blockNumber). +# 2. If LMM DAO is already deployed (deployer balance dropped), skip. +# 3. Clone dao-launchpad@f/lido-demo if missing. +# 4. Run `just demo-up` against anvil (deploys CR slice + LMM + mocks + seed). +# 5. Write manifest.json into MANIFEST_OUTPUT_DIR (default /srv/lmm) so +# nginx serves it as https://tests.aragon.in/manifest.json via the +# `alias` directive in nginx.lmm-demo.conf. +# +# Usage: +# MAINNET_RPC=... ./init-demo.sh +# # optionally: MANIFEST_OUTPUT_DIR=/srv/lmm ./init-demo.sh + +set -euo pipefail + +DAO_LAUNCHPAD_DIR="${DAO_LAUNCHPAD_DIR:-/srv/dao-launchpad}" +DAO_LAUNCHPAD_REPO="${DAO_LAUNCHPAD_REPO:-https://github.com/aragon/dao-launchpad.git}" +DAO_LAUNCHPAD_BRANCH="${DAO_LAUNCHPAD_BRANCH:-f/lido-demo}" +ANVIL_RPC="${ANVIL_RPC:-http://127.0.0.1:8545}" +MANIFEST_OUTPUT_DIR="${MANIFEST_OUTPUT_DIR:-/srv/lmm}" +PUBLIC_HOSTNAME="${PUBLIC_HOSTNAME:-tests.aragon.in}" +COMPOSE_FILE="${COMPOSE_FILE:-$(dirname "$(readlink -f "$0")")/docker-compose.yml}" + +log() { printf '\033[1;36m[init-demo]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[init-demo:ERROR]\033[0m %s\n' "$*" >&2; } + +require_cmd() { + if ! command -v "$1" &>/dev/null; then + err "missing required command: $1" + exit 1 + fi +} + +require_cmd git +require_cmd jq +require_cmd cast +require_cmd just +require_cmd forge + +log "waiting for anvil at $ANVIL_RPC..." +for i in $(seq 1 60); do + if cast block-number --rpc-url "$ANVIL_RPC" &>/dev/null; then + log "anvil ready" + break + fi + sleep 2 + if [ "$i" -eq 60 ]; then + err "anvil did not come up within 120s" + exit 1 + fi +done + +if [ ! -d "$DAO_LAUNCHPAD_DIR/.git" ]; then + log "cloning $DAO_LAUNCHPAD_REPO @ $DAO_LAUNCHPAD_BRANCH → $DAO_LAUNCHPAD_DIR" + git clone --branch "$DAO_LAUNCHPAD_BRANCH" --depth 1 --recurse-submodules \ + "$DAO_LAUNCHPAD_REPO" "$DAO_LAUNCHPAD_DIR" +else + log "dao-launchpad already cloned; pulling latest" + git -C "$DAO_LAUNCHPAD_DIR" fetch --depth 1 origin "$DAO_LAUNCHPAD_BRANCH" + git -C "$DAO_LAUNCHPAD_DIR" reset --hard "origin/$DAO_LAUNCHPAD_BRANCH" + git -C "$DAO_LAUNCHPAD_DIR" submodule update --init --recursive +fi + +cd "$DAO_LAUNCHPAD_DIR/lido" + +# `just switch mainnet` writes RPC_URL into .env — we don't need that since anvil is forked. +# But the foundry-tooling expects .env, so we synthesize one. +if [ ! -f .env ]; then + log "creating minimal .env for lido package" + cat >.env <"${MANIFEST_OUTPUT_DIR}/.env.indexer.tmp" </dev/null | grep -q '^envio$'; then + log "recreating envio container to apply new start_block" + docker compose -f "$COMPOSE_FILE" up -d --force-recreate envio +fi + +log "done. LMM DAO: $(jq -r .lmm.dao script/demo/manifest.json)" +log "manifest served at: https://${PUBLIC_HOSTNAME}/manifest.json" diff --git a/infra/lmm-demo/vm/nginx.lmm-demo.conf b/infra/lmm-demo/vm/nginx.lmm-demo.conf new file mode 100644 index 0000000000..fbd5959819 --- /dev/null +++ b/infra/lmm-demo/vm/nginx.lmm-demo.conf @@ -0,0 +1,141 @@ +# Lido Money Machine demo — nginx routing +# +# Drop-in for the existing nginx server that already terminates traffic for +# tests.aragon.in. Append the four `location` blocks below into that +# `server { ... }` (or `include` this file from inside it — see below). +# +# Topology assumed by these locations: +# +# Cloudflare (Flexible SSL, terminates TLS at edge) +# │ plain HTTP :80 over CF IP ranges only (UFW + nginx allow-list) +# ▼ +# host nginx (this config) ───► docker compose stack on 127.0.0.1 +# /rpc → anvil :8545 +# /graphql → hasura :8080 → /v1/graphql +# /manifest.json → file (host bind path) +# +# CORS is wide-open intentionally: this is throw-away demo infra, and the +# Aragon Vercel previews change origin per PR. Tighten by replacing the +# `*` with a specific allow-list once the demo is stable. +# +# Cloudflare-real-IP rewriting (so logs aren't all CF edge IPs): +# `include /etc/nginx/cloudflare-real-ip.conf;` should be set globally, +# generated from https://www.cloudflare.com/ips/ (refresh quarterly). + +# ──────────────────────────────────────────────────────────────────────────── +# Option A: paste these four `location` blocks directly into the existing +# `server { listen 80; server_name tests.aragon.in; ... }`. +# +# Option B: keep them here and reference via +# `include /etc/nginx/snippets/lmm-demo.conf;` from inside the +# tests.aragon.in server block. In that case rename this file +# to /etc/nginx/snippets/lmm-demo.conf. +# ──────────────────────────────────────────────────────────────────────────── + +# --------------------------------------------------------------------------- +# 1. JSON-RPC against anvil (mainnet fork). +# --------------------------------------------------------------------------- +location = /rpc { + # CORS preflight — must return 204 before proxying. nginx requires the + # full header set inside the `if` block (add_header doesn't inherit + # across `if` branches), so it's duplicated below for non-OPTIONS. + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Demo-Key"; + add_header Access-Control-Max-Age "86400"; + add_header Content-Length "0"; + add_header Content-Type "text/plain"; + return 204; + } + + proxy_pass http://127.0.0.1:8545/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Strip anvil's own CORS headers — anvil emits `Access-Control-Allow- + # Origin: *` + `Vary: origin, ...` on its own; without hiding those + # we'd end up with two ACA-Origin lines on every response and some + # browsers reject on duplicates. nginx is the single source of CORS. + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Vary; + + # `always` so headers also attach to 4xx/5xx from anvil (otherwise the + # browser blocks the response as a CORS error and you can't see it). + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Demo-Key" always; +} + +# --------------------------------------------------------------------------- +# 2. Envio's Hasura GraphQL (public role, read-only). +# --------------------------------------------------------------------------- +location = /graphql { + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Demo-Key, x-hasura-admin-secret"; + add_header Access-Control-Max-Age "86400"; + add_header Content-Length "0"; + add_header Content-Type "text/plain"; + return 204; + } + + # Hasura's GraphQL endpoint is /v1/graphql. We rewrite at the proxy + # layer so the public URL stays clean. + proxy_pass http://127.0.0.1:8080/v1/graphql; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + # Hasura supports subscriptions over WebSockets; uncomment if the demo + # uses them (we currently don't — Envio polls). + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + + # Strip Hasura's own CORS headers — even with HASURA_GRAPHQL_DISABLE_CORS + # we keep belt-and-braces here in case the compose env drifts. Hasura + # echoes `Origin: ` + `Access-Control-Allow-Credentials: true` + # by default, which conflicts with our wildcard origin and breaks the + # response in every modern browser. + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + proxy_hide_header Access-Control-Allow-Credentials; + proxy_hide_header Access-Control-Expose-Headers; + proxy_hide_header Vary; + + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Demo-Key, x-hasura-admin-secret" always; +} + +# --------------------------------------------------------------------------- +# 3. Manifest — static file written by init-demo.sh into MANIFEST_OUTPUT_DIR +# (default /srv/lmm). Must match that path here. +# --------------------------------------------------------------------------- +location = /manifest.json { + alias /srv/lmm/manifest.json; + default_type application/json; + + add_header Access-Control-Allow-Origin "*" always; + # Browsers cache aggressively; for a demo we want every refresh to + # pick up post-`init-demo.sh` rewrites. + add_header Cache-Control "no-store" always; +} + +# --------------------------------------------------------------------------- +# 4. Healthcheck (used by curl smoke-tests; no CORS needed). +# --------------------------------------------------------------------------- +location = /healthz { + default_type text/plain; + return 200 "ok\n"; +} diff --git a/infra/lmm-demo/vm/vm-README.md b/infra/lmm-demo/vm/vm-README.md new file mode 100644 index 0000000000..682f591f59 --- /dev/null +++ b/infra/lmm-demo/vm/vm-README.md @@ -0,0 +1,314 @@ +# Lido Money Machine demo — VM deployment + +Self-contained docker-compose stack for hosting the LMM demo behind a public +HTTPS endpoint. Single VM, four containers, ~3 GB RAM at idle. + +This is a deployment artifact — **for development, use [`../README.md`](../README.md)** +(local-first instructions, no VM required). + +--- + +## When to use the VM (vs local-first) + +| Scenario | VM? | Why | +|----------|-----|-----| +| Iterating on UI on your laptop | **No** — use `../README.md` | Faster feedback, no infra cost | +| Showing the demo from `localhost:3000` to one viewer | **No** | Local-first works | +| Demo on a **Vercel preview URL** (anyone can open the link) | **Yes** | Browsers block mixed content (HTTPS page → HTTP RPC). The VM gives Vercel-preview a public HTTPS origin | +| Persistent shared demo for the team | **Yes** | Single source of truth, no laptop in the loop | + +--- + +## Topology (what the public sees vs what stays internal) + +``` + public internet + │ + https://tests.aragon.in + │ + ▼ + ┌──────────────────────────────┐ + │ Cloudflare (Flexible SSL) │ TLS terminates here. + │ edge ↔ origin = plain HTTP │ No cert on the VM. + └──────────────┬───────────────┘ + │ HTTP :80, CF IPs only (UFW allow-list) + ▼ + ┌──────────────────────────────────────────────────────────┐ + │ VM (Ubuntu 24.04, Hetzner CX22 / DO 2vCPU / similar) │ + │ │ + │ ┌──────────────┐ │ + │ │ host nginx │ ← single public port: 80 │ + │ │ tests.aragon.in server block + LMM `location`s │ + │ └──┬───────────┘ │ + │ │ /rpc ──► 127.0.0.1:8545 (anvil) │ + │ │ /graphql ──► 127.0.0.1:8080 (hasura) │ + │ │ /manifest.json──► alias /srv/lmm/manifest.json │ + │ │ /healthz ──► 200 ok │ + │ │ + │ anvil ◄── envio (capital-flow-indexer) ──► hasura ──► postgres │ + │ (all four bind to 127.0.0.1 or docker-internal only) │ + └──────────────────────────────────────────────────────────┘ +``` + +Public endpoints (after `docker compose up -d` + `init-demo.sh`): + +- `https://tests.aragon.in/rpc` — JSON-RPC against anvil +- `https://tests.aragon.in/graphql` — Envio GraphQL (public role, read-only) +- `https://tests.aragon.in/manifest.json` — deployment manifest +- `https://tests.aragon.in/healthz` — liveness + +What stays internal (never reachable from the public internet): + +- anvil RPC (`127.0.0.1:8545` on the host) +- Hasura admin console (`127.0.0.1:8080`) +- Postgres (docker network only, no host port) +- Envio's HTTP API (docker network only) + +Enforcement: + +1. **docker-compose** binds anvil/hasura to `127.0.0.1` (not `0.0.0.0`), and postgres/envio expose nothing to the host. +2. **UFW** allows only 22 + 80 inbound (443 is unused because CF speaks plain HTTP to origin). +3. **Cloudflare** acts as the only legitimate caller; restricting nginx to CF IP ranges is recommended (see § Hardening below). + +--- + +## Roles — who owns what + +| Concern | Owner | Notes | +|---------|-------|-------| +| DNS for `tests.aragon.in` | DevOps | Already done — orange-cloud proxied through CF | +| Cloudflare zone / SSL mode | DevOps | Already on **Flexible**. No origin cert needed | +| Host nginx (system package) | DevOps | Already running for `tests.aragon.in` — we add `location` blocks | +| LMM docker-compose stack | Demo owner (you) | This directory | +| Mainnet archive RPC | Demo owner (you) | Alchemy/Infura free tier; set `MAINNET_RPC` in `.env` | +| Periodic redeploys / `init-demo.sh` | Demo owner (you) | One command, idempotent | + +What DevOps **does not** have to provide: + +- Any TLS certificate (Cloudflare handles it). +- An origin certificate (we're in Flexible mode, not Full-strict). +- A new server block — we're attaching to the existing one. +- Mainnet RPC credentials — you generate that yourself. + +--- + +## What's inside docker-compose + +| Service | Image | Host port | Purpose | +|----------|--------------------------------|---------------------|---------| +| anvil | `ghcr.io/foundry-rs/foundry` | `127.0.0.1:8545` | Mainnet fork; persistent state in `/data/anvil.json` | +| postgres | `postgres:17-alpine` | (none) | Envio's index DB | +| envio | `node:22-bookworm-slim` | (none) | Capital-flow-indexer; mounted from host | +| hasura | `hasura/graphql-engine:v2.42` | `127.0.0.1:8080` | GraphQL frontend for Envio's Postgres | + +There is **no** Caddy / nginx container in this stack — TLS + routing is the host's job. + +### Why nginx (host) and not a containerised proxy? + +Because the VM **already runs host nginx for `tests.aragon.in`**. Adding a second proxy inside docker would either: + +- conflict on port 80 (compose can't bind it if nginx is already bound), or +- chain proxies (CF → host nginx → container proxy → upstreams), adding a hop with no benefit. + +Cloudflare also makes Let's Encrypt / ACME irrelevant — the cert lives on CF edge, so the origin proxy reduces to "path routing + CORS", which is exactly what the existing nginx is built for. See [`nginx.lmm-demo.conf`](./nginx.lmm-demo.conf) — ~80 lines including comments. + +--- + +## Prerequisites on the VM + +```bash +# Ubuntu 22.04/24.04 LTS +sudo apt update +sudo apt install -y docker.io docker-compose-plugin git jq curl + +# foundry (forge/cast/anvil) — used by init-demo.sh, not by docker +curl -L https://foundry.paradigm.xyz | bash +~/.foundry/bin/foundryup + +# just (task runner) — used by init-demo.sh +sudo apt install -y just || \ + (curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | sudo bash -s -- --to /usr/local/bin) +``` + +A mainnet **archive** RPC URL is required for the fork; put it in +`/srv/app/infra/lmm-demo/vm/.env` as `MAINNET_RPC=https://...`. Public RPCs +(`drpc.org`, `llamarpc.com`) rate-limit fork genesis and will fail. + +Firewall: `sudo ufw allow 22,80/tcp` (no 443 — CF terminates TLS upstream). + +--- + +## First boot + +```bash +# 1. Mount the capital-flow-indexer source on the host (sibling of this dir). +cd /srv +git clone https://github.com/aragon/capital-flow-indexer.git + +# 2. Pull the app repo onto the VM. +cd /srv +git clone -b money-mchine-dashboard https://github.com/aragon/app.git # NB: branch name has a typo on purpose +cd app/infra/lmm-demo/vm + +# 3. Configure the compose stack. +cp .env.vm.example .env +$EDITOR .env # set MAINNET_RPC + POSTGRES_PASSWORD + HASURA_ADMIN_SECRET + +# 4. Start the stack (no proxy container — that's host nginx). +docker compose up -d +docker compose logs -f anvil # watch the fork sync — should take 5-30s + +# 5. Bootstrap the demo DAO (idempotent). Writes manifest into /srv/lmm. +sudo mkdir -p /srv/lmm && sudo chown "$USER" /srv/lmm +./init-demo.sh + +# 6. Plumb host nginx — append the LMM `location` blocks to the existing +# tests.aragon.in server. Two equivalent ways: +# +# (a) Include the file as-is: +sudo cp nginx.lmm-demo.conf /etc/nginx/snippets/lmm-demo.conf +# then inside `server { server_name tests.aragon.in; ... }` add +# include /etc/nginx/snippets/lmm-demo.conf; +# +# (b) Or just paste the four `location` blocks directly into that +# server block. +# +sudo nginx -t && sudo systemctl reload nginx + +# 7. Verify from outside (run on your laptop, not on the VM): +curl -s https://tests.aragon.in/healthz # → ok +curl -s https://tests.aragon.in/manifest.json | jq .lmm.dao # → 0x... +curl -s -X POST -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + https://tests.aragon.in/rpc | jq .result # → 0x... +curl -s -X POST -H 'Content-Type: application/json' \ + -d '{"query":"query { Dao { id address } }"}' \ + https://tests.aragon.in/graphql | jq . # → list with LMM DAO + +# 8. Wire the Aragon app's Vercel preview env to point at this VM: +# see ../vercel.env.example for the four variables. The hostname +# (tests.aragon.in) is already in LMM_RPC_ALLOWLIST in +# src/modules/flow/demo/lmmDemoConfig.ts. +``` + +If step 7 returns Cloudflare error **521** instead of the expected payload, the LMM container ports are up but **host nginx isn't proxying** — re-check step 6 (`nginx -t` for syntax, then `systemctl reload nginx`). If it returns **502**, the location is hit but the upstream is down — `docker compose ps` to see which container is unhealthy. + +--- + +## Day-2 ops + +### Refresh demo state (clean restart) + +```bash +docker compose down -v # drops anvil state + indexer DB +docker compose up -d +./init-demo.sh # re-deploys, rewrites manifest.json atomically +``` + +### Anvil only (keep indexer DB) + +```bash +docker compose restart anvil +./init-demo.sh # re-deploys against fresh fork +# After that you usually want to re-index too: +docker compose restart envio +``` + +### Inspect a tx + +```bash +docker compose exec anvil sh -c 'cast tx 0x... --rpc-url http://localhost:8545' +``` + +### Tail logs + +```bash +docker compose logs -f --tail=100 # all services +docker compose logs -f anvil envio # specific +sudo journalctl -u nginx -f # host nginx (separate process) +``` + +--- + +## Updating the indexer + +The Envio container mounts `capital-flow-indexer/` from the host. To deploy +changes: + +```bash +cd /srv/capital-flow-indexer +git pull +docker compose -f /srv/app/infra/lmm-demo/vm/docker-compose.yml restart envio +``` + +If `config.yaml` or `schema.graphql` changes, the next `pnpm codegen + pnpm start` +inside the container picks it up automatically. + +--- + +## Hardening (recommended once the demo is stable) + +The defaults are deliberately permissive (CORS `*`, no client allow-list, +admin secret = `changeme`). Before any non-team viewer hits the link: + +1. **Restrict nginx :80 to Cloudflare IP ranges.** Stops attackers from + pointing a custom DNS record at the VM and bypassing CF. + + ```nginx + # /etc/nginx/conf.d/cloudflare-only.conf (in the http {} or per server) + # Generated from https://www.cloudflare.com/ips/ + allow 173.245.48.0/20; + allow 103.21.244.0/22; + # ... (full list) + deny all; + ``` + + Also enforce at L3: `sudo ufw allow proto tcp from 173.245.48.0/20 to any port 80` etc., then `sudo ufw delete allow 80/tcp`. + +2. **Trust `CF-Connecting-IP` for `$remote_addr`** so logs aren't all CF + edge IPs and rate-limits target real clients. + + ```nginx + real_ip_header CF-Connecting-IP; + set_real_ip_from 173.245.48.0/20; + # ... (one line per CF range) + ``` + +3. **Replace `*` CORS** in `nginx.lmm-demo.conf` with the explicit Vercel preview + pattern (e.g. `https://app-git-*.vercel.app` plus the production aragon + domain). + +4. **Rotate `HASURA_ADMIN_SECRET`** out of `changeme`. The console is never + proxied publicly, but anyone with SSH on the VM can reach it on + `127.0.0.1:8080`. + +5. **Add BasicAuth on `/rpc`** via `auth_basic`. The Aragon app sends an + `X-Demo-Key` header (configurable in the app) — wire it through. + +--- + +## Security model + +This is **throw-away demo infrastructure**. + +- The anvil fork uses a hard-coded deployer private key (anvil[0]); anybody + with the preview URL can fire dispatches. That's intentional for demos. +- The Hasura console is gated by `HASURA_ADMIN_SECRET`, but the public + GraphQL endpoint exposes read-only queries via the `public` role. +- CORS is `*` — production has stricter rules but the demo welcomes any + Vercel preview origin. Tighten via the Hardening section above. + +--- + +## Cost notes + +| Provider | Plan | Approx €/mo | +|-------------------|-------------------------------|-------------| +| Hetzner Cloud | CX22 (2vCPU, 4GB, 40GB SSD) | ~5 | +| DigitalOcean | s-2vcpu-4gb | ~24 | +| AWS Lightsail | 2vcpu-4gb-80gb | ~22 | + +The compose stack idles around 1-2 GB RAM and <5% CPU; spikes during a +dispatch sequence to ~80% CPU on one core. Mainnet RPC usage is the main +external cost — bound to one fork-init per anvil restart (~10k requests), +then very low. diff --git a/package.json b/package.json index e20096218b..61db3b6472 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@aragon/gov-ui-kit": "^2.6.0", + "@dagrejs/dagre": "^3.0.0", "@floating-ui/react": "^0.27.19", "@number-flow/react": "^0.6.0", "@radix-ui/react-dialog": "^1.1.15", @@ -50,6 +51,7 @@ "@tanstack/react-query-devtools": "^5.101.0", "@walletconnect/core": "^2.23.9", "@walletconnect/utils": "^2.23.9", + "@xyflow/react": "^12.10.2", "classnames": "^2.5.1", "deepmerge-ts": "^7.1.5", "framer-motion": "^12.40.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da9d0f59da..6e8f20380e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: '@aragon/gov-ui-kit': specifier: ^2.6.0 version: 2.6.0(@emotion/is-prop-valid@1.4.0)(@floating-ui/dom@1.7.6)(@tailwindcss/typography@0.5.19(tailwindcss@4.3.0))(@tanstack/react-query@5.101.0(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react-hook-form@7.77.0(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.0)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))(wagmi@3.6.16(@coinbase/wallet-sdk@4.3.6(@types/react@19.2.16)(immer@11.1.8)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.7))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(@types/react@19.2.16)(immer@11.1.8)(react@19.2.7)(typescript@5.9.3)(viem@2.52.2(typescript@5.9.3)(zod@3.25.76))) + '@dagrejs/dagre': + specifier: ^3.0.0 + version: 3.0.0 '@floating-ui/react': specifier: ^0.27.19 version: 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -73,6 +76,9 @@ importers: '@walletconnect/utils': specifier: ^2.23.9 version: 2.23.9(@vercel/blob@2.3.0)(typescript@5.9.3)(zod@3.25.76) + '@xyflow/react': + specifier: ^12.10.2 + version: 12.10.2(@types/react@19.2.16)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) classnames: specifier: ^2.5.1 version: 2.5.1 @@ -667,6 +673,12 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dagrejs/dagre@3.0.0': + resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==} + + '@dagrejs/graphlib@4.0.1': + resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} + '@depay/solana-web3.js@1.98.3': resolution: {integrity: sha512-wxr+2gpjKRZ1eVBLhQYJxImDsRukk0DvCsEElkTMyybP+7SamWRs48o3DYE6VLEgQJFZgOoUec3t5FM5s1J1ww==} @@ -3592,6 +3604,9 @@ packages: '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -3604,6 +3619,9 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.8': resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} @@ -3613,6 +3631,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -4130,6 +4154,15 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@xyflow/react@12.10.2': + resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.76': + resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==} + '@yarnpkg/parsers@3.0.3': resolution: {integrity: sha512-mQZgUSgFurUtA07ceMjxrWkYz8QtDuYkvPlu0ZqncgjopQ0t6CNEo/OSealkmnagSUx8ZD5ewvezUwUuMqutQg==} engines: {node: '>=18.12.0'} @@ -4583,6 +4616,9 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -4740,6 +4776,14 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} @@ -4760,6 +4804,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -4776,6 +4824,16 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -7967,6 +8025,21 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.0: resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==} engines: {node: '>=12.20.0'} @@ -8602,6 +8675,12 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dagrejs/dagre@3.0.0': + dependencies: + '@dagrejs/graphlib': 4.0.1 + + '@dagrejs/graphlib@4.0.1': {} + '@depay/solana-web3.js@1.98.3': dependencies: bs58: 5.0.0 @@ -11968,6 +12047,10 @@ snapshots: '@types/d3-color@3.1.3': {} + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} '@types/d3-interpolate@3.0.4': @@ -11980,6 +12063,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 @@ -11988,6 +12073,15 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/estree@1.0.9': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -13070,6 +13164,29 @@ snapshots: '@xtuc/long@4.2.2': {} + '@xyflow/react@12.10.2(@types/react@19.2.16)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@xyflow/system': 0.0.76 + classcat: 5.0.5 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + zustand: 4.5.7(@types/react@19.2.16)(immer@11.1.8)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.76': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + '@yarnpkg/parsers@3.0.3': dependencies: js-yaml: 3.14.2 @@ -13480,6 +13597,8 @@ snapshots: cjs-module-lexer@2.2.0: {} + classcat@5.0.5: {} + classnames@2.5.1: {} cli-cursor@5.0.0: @@ -13615,6 +13734,13 @@ snapshots: d3-color@3.1.0: {} + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-ease@3.0.1: {} d3-format@3.1.2: {} @@ -13633,6 +13759,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -13647,6 +13775,23 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-uri-to-buffer@6.0.2: {} data-urls@5.0.0: @@ -17177,6 +17322,14 @@ snapshots: zod@4.4.3: {} + zustand@4.5.7(@types/react@19.2.16)(immer@11.1.8)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + immer: 11.1.8 + react: 19.2.7 + zustand@5.0.0(@types/react@19.2.16)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.16 diff --git a/src/app/dao/[network]/[addressOrEns]/flow/activity/page.tsx b/src/app/dao/[network]/[addressOrEns]/flow/activity/page.tsx new file mode 100644 index 0000000000..77a0547f58 --- /dev/null +++ b/src/app/dao/[network]/[addressOrEns]/flow/activity/page.tsx @@ -0,0 +1,3 @@ +import { FlowActivityPage } from '@/modules/flow'; + +export default FlowActivityPage; diff --git a/src/app/dao/[network]/[addressOrEns]/flow/layout.tsx b/src/app/dao/[network]/[addressOrEns]/flow/layout.tsx new file mode 100644 index 0000000000..1d2a2abc9f --- /dev/null +++ b/src/app/dao/[network]/[addressOrEns]/flow/layout.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; +import { FlowLayout } from '@/modules/flow'; +import type { IDaoPageParams } from '@/shared/types'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { networkUtils } from '@/shared/utils/networkUtils'; + +export interface IFlowLayoutRouteProps { + children?: ReactNode; + params: Promise; +} + +const FlowLayoutRoute = async (props: IFlowLayoutRouteProps) => { + const { params, children } = props; + const { network, addressOrEns } = await params; + + if (!networkUtils.isValidNetwork(network)) { + return <>{children}; + } + + const daoId = await daoUtils.resolveDaoId({ network, addressOrEns }); + + return ( + + {children} + + ); +}; + +export default FlowLayoutRoute; diff --git a/src/app/dao/[network]/[addressOrEns]/flow/page.tsx b/src/app/dao/[network]/[addressOrEns]/flow/page.tsx new file mode 100644 index 0000000000..cb52545bfc --- /dev/null +++ b/src/app/dao/[network]/[addressOrEns]/flow/page.tsx @@ -0,0 +1,3 @@ +import { FlowOverviewPage } from '@/modules/flow'; + +export default FlowOverviewPage; diff --git a/src/app/dao/[network]/[addressOrEns]/flow/policies/[id]/page.tsx b/src/app/dao/[network]/[addressOrEns]/flow/policies/[id]/page.tsx new file mode 100644 index 0000000000..605bfb53cf --- /dev/null +++ b/src/app/dao/[network]/[addressOrEns]/flow/policies/[id]/page.tsx @@ -0,0 +1,3 @@ +import { FlowPolicyDetailPage } from '@/modules/flow'; + +export default FlowPolicyDetailPage; diff --git a/src/app/dao/[network]/[addressOrEns]/flow/recipients/page.tsx b/src/app/dao/[network]/[addressOrEns]/flow/recipients/page.tsx new file mode 100644 index 0000000000..49451c5483 --- /dev/null +++ b/src/app/dao/[network]/[addressOrEns]/flow/recipients/page.tsx @@ -0,0 +1,3 @@ +import { FlowRecipientsPage } from '@/modules/flow'; + +export default FlowRecipientsPage; diff --git a/src/app/dao/[network]/[addressOrEns]/flow/workbench/page.tsx b/src/app/dao/[network]/[addressOrEns]/flow/workbench/page.tsx new file mode 100644 index 0000000000..175d8521c5 --- /dev/null +++ b/src/app/dao/[network]/[addressOrEns]/flow/workbench/page.tsx @@ -0,0 +1,3 @@ +import { FlowWorkbenchPage } from '@/modules/flow'; + +export default FlowWorkbenchPage; diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 6c0e839980..f128c1e325 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -403,6 +403,7 @@ "link": { "assets": "Assets", "dashboard": "Dashboard", + "flow": "Capital flow", "members": "Members", "proposals": "Proposals", "settings": "Settings", @@ -745,6 +746,10 @@ "submitLabel": "Publish policy" }, "dispatchDialog": { + "a11y": { + "description": "Choose how to dispatch this policy", + "title": "Dispatch policy" + }, "backButton": "Back", "description": "You can dispatch this policy by confirming the transactions using your connected wallet.", "dispatchAllButton": "Dispatch all", @@ -768,6 +773,10 @@ "unnamedSubRouter": "Unnamed sub-router" }, "dispatchSimulationDialog": { + "a11y": { + "description": "Review the dispatch simulation before confirming the transaction", + "title": "Dispatch simulation" + }, "backButton": "Back", "cancel": "Cancel", "continue": "Continue to dispatch", @@ -795,6 +804,10 @@ "title": "Automated transactions" }, "dispatchTransactionDialog": { + "a11y": { + "description": "Review and submit the dispatch transaction in your wallet", + "title": "Dispatch transaction" + }, "description": "To dispatch this router, confirm the onchain transaction in your wallet.", "dispatchButton": "Dispatch", "successButton": "Done", diff --git a/src/modules/application/components/navigations/navigationDao/navigationDaoUtils.ts b/src/modules/application/components/navigations/navigationDao/navigationDaoUtils.ts index 6cd13098b7..260f7d68ec 100644 --- a/src/modules/application/components/navigations/navigationDao/navigationDaoUtils.ts +++ b/src/modules/application/components/navigations/navigationDao/navigationDaoUtils.ts @@ -108,6 +108,20 @@ class NavigationDaoUtils { hidden: isPageContext, order: 600, }, + // Flow POC — only surface the Capital flow entry point inside the + // DAO navigation dialog (desktop dropdown + mobile sheet), and only + // when the Envio-backed POC is enabled. We deliberately keep it out + // of the page-level horizontal nav to avoid cluttering the main + // rail while the feature is still in preview. + { + label: 'app.application.navigationDao.link.flow', + link: `${baseUrl}/flow`, + icon: IconType.APP_TRANSACTIONS, + hidden: + isPageContext || + process.env.NEXT_PUBLIC_FLOW_USE_ENVIO !== 'true', + order: 650, + }, ]; }; diff --git a/src/modules/application/components/providers/providersDialogs.ts b/src/modules/application/components/providers/providersDialogs.ts index ffc3d9bf92..d62a4747b0 100644 --- a/src/modules/application/components/providers/providersDialogs.ts +++ b/src/modules/application/components/providers/providersDialogs.ts @@ -4,6 +4,7 @@ import { applicationDialogsDefinitions } from '@/modules/application/constants/a import { capitalFlowDialogsDefinitions } from '@/modules/capitalFlow/constants/capitalFlowDialogsDefinitions'; import { createDaoDialogsDefinitions } from '@/modules/createDao/constants/createDaoDialogsDefinitions'; import { financeDialogsDefinitions } from '@/modules/finance/constants/financeDialogsDefinitions'; +import { flowDialogsDefinitions } from '@/modules/flow/constants/flowDialogsDefinitions'; import { governanceDialogsDefinitions } from '@/modules/governance/constants/governanceDialogsDefinitions'; import { settingsDialogDefinitions } from '@/modules/settings/constants/settingsDialogDefinitions'; import { pluginDialogsDefinitions } from '@/plugins'; @@ -17,6 +18,7 @@ export const providersDialogs: Record = { ...pluginDialogsDefinitions, ...settingsDialogDefinitions, ...capitalFlowDialogsDefinitions, + ...flowDialogsDefinitions, ...actionsDialogsDefinitions, ...daosDialogsDefinitions, }; diff --git a/src/modules/capitalFlow/constants/capitalFlowDialogsDefinitions.ts b/src/modules/capitalFlow/constants/capitalFlowDialogsDefinitions.ts index 9418991f3e..9de200c678 100644 --- a/src/modules/capitalFlow/constants/capitalFlowDialogsDefinitions.ts +++ b/src/modules/capitalFlow/constants/capitalFlowDialogsDefinitions.ts @@ -29,13 +29,30 @@ export const capitalFlowDialogsDefinitions: Record< Component: RouterSelectorDialog, size: 'lg', }, - [CapitalFlowDialogId.DISPATCH]: { Component: DispatchDialog, size: 'lg' }, + // The three dispatch dialogs all advertise `hiddenTitle` + `hiddenDescription` + // so Radix never warns about a missing `DialogTitle` while the dialog + // shell is mounted but the inner component is still resolving (e.g. + // `DispatchTransactionDialog` returns `null` while the LMM manifest + // loads). Once the inner component renders its own visible `Dialog.Header`, + // Radix prefers that one and the hidden values are inert. + [CapitalFlowDialogId.DISPATCH]: { + Component: DispatchDialog, + size: 'lg', + hiddenTitle: 'app.capitalFlow.dispatchDialog.a11y.title', + hiddenDescription: 'app.capitalFlow.dispatchDialog.a11y.description', + }, [CapitalFlowDialogId.DISPATCH_SIMULATION]: { Component: DispatchSimulationDialog, size: 'lg', + hiddenTitle: 'app.capitalFlow.dispatchSimulationDialog.a11y.title', + hiddenDescription: + 'app.capitalFlow.dispatchSimulationDialog.a11y.description', }, [CapitalFlowDialogId.DISPATCH_TRANSACTION]: { Component: DispatchTransactionDialog, size: 'lg', + hiddenTitle: 'app.capitalFlow.dispatchTransactionDialog.a11y.title', + hiddenDescription: + 'app.capitalFlow.dispatchTransactionDialog.a11y.description', }, }; diff --git a/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchSimulationDialog.tsx b/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchSimulationDialog.tsx index b4b9270b1f..fd636b6d3a 100644 --- a/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchSimulationDialog.tsx +++ b/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchSimulationDialog.tsx @@ -32,6 +32,13 @@ export interface IDispatchSimulationDialogParams { network: Network; showBackButton?: boolean; routerSelectorParams?: IRouterSelectorDialogParams; + /** + * Forwarded to `DispatchTransactionDialog` on successful simulation → + * continue. Used by the Flow page to wire optimistic "waiting for + * indexer" UI as soon as the tx broadcasts, without each consumer having + * to open the transaction dialog directly. + */ + onDispatchSuccess?: IDispatchTransactionDialogParams['onDispatchSuccess']; } export interface IDispatchSimulationDialogProps @@ -52,6 +59,7 @@ export const DispatchSimulationDialog: React.FC< network, showBackButton = false, routerSelectorParams, + onDispatchSuccess, } = location.params; const { t } = useTranslations(); @@ -126,6 +134,7 @@ export const DispatchSimulationDialog: React.FC< network, showBackButton, routerSelectorParams, + onDispatchSuccess, }; close(CapitalFlowDialogId.DISPATCH_SIMULATION); diff --git a/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx b/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx index 43371fc01c..d884ac0358 100644 --- a/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx +++ b/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx @@ -1,7 +1,13 @@ import { invariant } from '@aragon/gov-ui-kit'; +import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; -import { encodeFunctionData, type Hex } from 'viem'; +import { encodeFunctionData, type Hex, type TransactionReceipt } from 'viem'; import { useWalletAccount } from '@/modules/application/hooks/useWalletAccount'; +// LMM_DEMO_HACK: detect Lido Money Machine demo policies and route them to +// the Anvil-fork dispatch flow instead of wagmi. Production DAOs always +// take the original code path below. +import { LMM_DEMO_MODE } from '@/modules/flow/demo/lmmDemoConfig'; +import { useIsLmmDemoDao } from '@/modules/flow/demo/useLmmManifest'; import type { Network } from '@/shared/api/daoService'; import type { IDaoPolicy } from '@/shared/api/daoService/domain/daoPolicy'; import { @@ -18,6 +24,20 @@ import { useStepper } from '@/shared/hooks/useStepper'; import { CapitalFlowDialogId } from '../../constants/capitalFlowDialogId'; import type { IRouterSelectorDialogParams } from '../routerSelectorDialog'; +// LMM_DEMO_HACK: lazy-load the demo dialog so its imports (anvil deployer +// key, vendored preview-lib, viem/private-key signing) never land in the +// production bundle when LMM_DEMO_MODE=0. `ssr: false` keeps it +// client-only, matching the original eager import. +const LmmDemoDispatchDialog = LMM_DEMO_MODE + ? dynamic( + () => + import('./lmmDemoDispatchDialog').then( + (m) => m.LmmDemoDispatchDialog, + ), + { ssr: false }, + ) + : null; + export interface IDispatchTransactionDialogParams { policy: IDaoPolicy; network: Network; @@ -31,6 +51,15 @@ export interface IDispatchTransactionDialogParams { * Only used when showBackButton is true. */ routerSelectorParams?: IRouterSelectorDialogParams; + /** + * Invoked as soon as the broadcast transaction is confirmed (one receipt). The + * receiving side can use the tx hash to wire optimistic / indexer-lag UI without + * waiting for the user to click through the final success step. + */ + onDispatchSuccess?: (params: { + txHash: string; + receipt: TransactionReceipt; + }) => void; } export interface IDispatchTransactionDialogProps @@ -51,6 +80,37 @@ export const DispatchTransactionDialog: React.FC< > = (props) => { const { location } = props; + invariant( + location.params != null, + 'DispatchTransactionDialog: required parameters must be set.', + ); + const { policy } = location.params; + + // Route to the Anvil-fork demo dispatch flow when the policy belongs to + // the LMM demo DAO. `useIsLmmDemoDao` returns `undefined` until the + // manifest has loaded — when demo mode is on, we wait (show nothing) + // rather than briefly mount the production wagmi flow. All hooks + // below this branch are inside `ProductionDispatchDialog` to keep + // the hook order stable. + const isLmmDemo = useIsLmmDemoDao(policy.daoAddress); + if (LMM_DEMO_MODE && LmmDemoDispatchDialog != null) { + if (isLmmDemo === undefined) { + // Manifest still loading — render nothing so a demo DAO never + // briefly opens the production wallet dialog while the + // manifest fetch is in flight. + return null; + } + if (isLmmDemo) { + return ; + } + } + return ; +}; + +const ProductionDispatchDialog: React.FC = ( + props, +) => { + const { location } = props; invariant( location.params != null, 'DispatchTransactionDialog: required parameters must be set.', @@ -60,6 +120,7 @@ export const DispatchTransactionDialog: React.FC< network, showBackButton = false, routerSelectorParams, + onDispatchSuccess, } = location.params; const { address } = useWalletAccount(); @@ -104,12 +165,23 @@ export const DispatchTransactionDialog: React.FC< } }; + const handleReceipt = (receipt: TransactionReceipt) => { + if (onDispatchSuccess == null) { + return; + } + onDispatchSuccess({ + txHash: receipt.transactionHash, + receipt, + }); + }; + return ( { + if (cachedClient) { + return cachedClient; + } + cachedClient = createPublicClient({ + chain: mainnet, + transport: http(LMM_RPC_URL), + }); + return cachedClient; +}; + +type DispatchPhase = 'preparing' | 'ready' | 'sending' | 'success' | 'error'; + +export const LmmDemoDispatchDialog: React.FC< + IDispatchTransactionDialogProps +> = (props) => { + const { location } = props; + const params = location.params as IDispatchTransactionDialogParams; + const { + policy, + showBackButton = false, + routerSelectorParams, + onDispatchSuccess, + } = params; + + const router = useRouter(); + const { open, close } = useDialogContext(); + const { manifest } = useLmmManifest(); + + const [phase, setPhase] = useState('preparing'); + const [flow, setFlow] = useState(null); + const [simError, setSimError] = useState(undefined); + const [txHash, setTxHash] = useState(undefined); + const [txError, setTxError] = useState(undefined); + + const ctxRef = useRef(undefined); + + // ---- Step 1: build ActionContext + simulate the next dispatch --------- + useEffect(() => { + let cancelled = false; + if (!LMM_DEMO_MODE || !manifest) { + return; + } + const run = async () => { + try { + const publicClient = getPublicClient(); + const dispatcher = manifest.lmm.dispatcherPlugin as Address; + const dao = manifest.lmm.dao as Address; + const addresses = deriveAddressesFromManifest( + manifest as unknown as Parameters< + typeof deriveAddressesFromManifest + >[0], + ); + if (!addresses) { + throw new Error( + 'demo manifest is missing one or more required addresses', + ); + } + ctxRef.current = { + rpc: LMM_RPC_URL, + publicClient, + dao, + dispatcher, + addresses, + }; + const topology: TopologyGraph = await inspect( + publicClient, + dispatcher, + ); + const simulated = await simulate(publicClient, topology); + if (cancelled) { + return; + } + setFlow(simulated); + setPhase('ready'); + } catch (e) { + if (cancelled) { + return; + } + setSimError(e instanceof Error ? e.message : String(e)); + setPhase('ready'); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [manifest]); + + const handleCancel = () => { + if (phase === 'sending') { + return; + } + close(CapitalFlowDialogId.DISPATCH_TRANSACTION); + if (showBackButton && routerSelectorParams) { + open(CapitalFlowDialogId.ROUTER_SELECTOR, { + params: routerSelectorParams, + }); + } + }; + + const handleConfirm = async () => { + if (!ctxRef.current) { + return; + } + setPhase('sending'); + setTxError(undefined); + try { + assertForkRpc(); + const hash = await dispatchAction(ctxRef.current); + setTxHash(hash); + setPhase('success'); + if (onDispatchSuccess) { + // Fetch the full receipt so optimistic callers (waiting-for- + // indexer badge, etc) have something to read. + const receipt = + await ctxRef.current.publicClient.waitForTransactionReceipt( + { hash }, + ); + onDispatchSuccess({ txHash: hash, receipt }); + } + } catch (e) { + setTxError(e instanceof Error ? e.message : String(e)); + setPhase('error'); + } + }; + + const handleDone = () => { + close(CapitalFlowDialogId.DISPATCH_TRANSACTION); + router.refresh(); + }; + + const title = useMemo( + () => `Dispatch ${policy.name ?? 'policy'} (demo)`, + [policy.name], + ); + const description = + 'This dispatches on the demo Anvil fork. No real funds move. ' + + 'Cheats: open the policy page actions menu to warp time or top up balances.'; + + return ( + <> + + +
+ + Real chains are not touched. + + + {phase === 'preparing' && ( +
+ Computing the next dispatch… +
+ )} + {simError && ( + + )} + {flow && } + {txError && ( + + )} + {phase === 'success' && txHash && ( + + )} +
+
+ + + ); +}; diff --git a/src/modules/flow/canvas/buildFlowGraph.test.ts b/src/modules/flow/canvas/buildFlowGraph.test.ts new file mode 100644 index 0000000000..01ab85a419 --- /dev/null +++ b/src/modules/flow/canvas/buildFlowGraph.test.ts @@ -0,0 +1,219 @@ +import { buildFlowGraph } from './buildFlowGraph'; +import type { IFlowDynamics, IFlowMachineDescriptor } from './flowGraphTypes'; + +/** + * Reference machine: wrap, add-liquidity, gated-buyback, mirroring the Lido + * Money Machine. Built from a generic descriptor + dynamics — nothing here is + * LMM-specific in the builder. The legs are hub-and-spoke around the ONE vault: + * each draws from it and returns proceeds to it, so the test proves real + * per-leg vault-out/vault-in edges (no fabricated leg→leg pipeline) and a + * net-to-vault that includes the value a leg retains. + */ +const descriptor: IFlowMachineDescriptor = { + id: 'orch-1', + source: { label: 'LMM DAO Vault', address: '0xdao' }, + recipient: { address: '0xagent', label: 'Lido Agent' }, + steps: [ + { + index: 0, + address: '0xwrap', + kind: 'wrap', + label: 'Wrap', + paused: false, + inputs: [{ role: 'budget', kind: 'full', label: 'Full Budget' }], + }, + { + index: 1, + address: '0xuniv2', + kind: 'univ2Liquidity', + label: 'Add Liquidity', + paused: false, + inputs: [ + { role: 'budget', kind: 'full', label: 'Full Budget' }, + { + role: 'budget', + kind: 'streamUntil', + label: 'Stream-Until Budget', + }, + ], + }, + { + index: 2, + address: '0xcow', + kind: 'gatedCowSwap', + label: 'Buyback', + paused: false, + inputs: [ + { + role: 'budget', + kind: 'streamUntil', + label: 'Stream-Until Budget', + }, + { role: 'gate', kind: 'priceFloor', label: 'Price-Floor Gate' }, + { role: 'epoch', kind: 'epoch', label: 'Epoch Provider' }, + ], + }, + ], +}; + +const dynamics: IFlowDynamics = { + balances: [ + { token: 'stETH', amount: 100 }, + { token: 'wstETH', amount: 12.4 }, + { token: 'LDO', amount: 36.4 }, + ], + steps: [ + { + address: '0xwrap', + index: 0, + state: 'accumulating', + ins: [{ token: 'stETH', amount: 100, fidelity: 'real' }], + outs: [{ token: 'wstETH', amount: 85.6, fidelity: 'estimated' }], + }, + { + address: '0xuniv2', + index: 1, + state: 'firing', + ins: [ + { token: 'LDO', amount: 36.4, fidelity: 'real' }, + { + token: 'wstETH', + amount: 0.58, + fidelity: 'estimated', + perEpoch: true, + }, + ], + outs: [{ token: 'LP', amount: null, fidelity: 'opaque' }], + }, + { + address: '0xcow', + index: 2, + state: 'blocked', + blocked: true, + skipReason: 'gate closed', + ins: [ + { + token: 'wstETH', + amount: 0.58, + fidelity: 'estimated', + perEpoch: true, + }, + ], + outs: [{ token: 'LDO', amount: null, fidelity: 'opaque' }], + }, + ], +}; + +describe('buildFlowGraph', () => { + it('builds real per-leg vault-out/vault-in edges (no inferred pipeline)', () => { + const graph = buildFlowGraph({ descriptor, dynamics }); + + // 4 feeds (vault→leg draws) + 3 returns (leg→vault proceeds). + const feeds = graph.edges.filter((e) => e.kind === 'feeds'); + const returns = graph.edges.filter((e) => e.kind === 'returns'); + expect(feeds).toHaveLength(4); + expect(returns).toHaveLength(3); + // Nothing is inferred between legs, and nothing distributes externally + // (all proceeds loop back to the one vault). + expect(graph.edges.every((e) => e.kind !== 'distributes')).toBe(true); + + // Every feed leaves the vault; every return lands back in it. + expect(feeds.every((e) => e.source === 'source')).toBe(true); + expect(returns.every((e) => e.target === 'source')).toBe(true); + + // The liquidity leg draws BOTH its tokens straight from the vault — its + // wstETH is no longer a fabricated pipeline edge from wrap. + const univ2Feeds = feeds.filter((e) => e.target === '0xuniv2'); + expect(univ2Feeds.map((e) => e.token).sort()).toEqual([ + 'LDO', + 'wstETH', + ]); + // The streamed slice keeps its /epoch badge. + expect(univ2Feeds.find((e) => e.token === 'wstETH')?.perEpoch).toBe( + true, + ); + }); + + it('surfaces net-to-vault including the retained wrap output', () => { + const graph = buildFlowGraph({ descriptor, dynamics }); + const net = graph.nodes.find((n) => n.id === 'source')?.net; + const wstETH = net?.find((e) => e.token === 'wstETH'); + + // wrap returns 85.6 wstETH; univ2 + cow each draw a 0.58 slice back out → + // ~84.44 net retained in the vault (the value the old model hid). + expect(wstETH?.delta).toBeCloseTo(85.6 - 0.58 - 0.58, 5); + // stETH only ever leaves. + expect(net?.find((e) => e.token === 'stETH')?.delta).toBe(-100); + }); + + it('marks the blocked buyback output and flowing legs correctly', () => { + const graph = buildFlowGraph({ descriptor, dynamics }); + const cowOut = graph.edges.find((e) => e.id === 'out-0xcow-LDO-0'); + const univ2Out = graph.edges.find((e) => e.id === 'out-0xuniv2-LP-0'); + + expect(cowOut?.kind).toBe('returns'); + expect(cowOut?.blocked).toBe(true); + expect(cowOut?.flowing).toBe(false); + expect(univ2Out?.flowing).toBe(true); + expect(univ2Out?.fidelity).toBe('opaque'); + }); + + it('lays out the single vault hub left of the legs, no duplicate treasury', () => { + const graph = buildFlowGraph({ descriptor, dynamics }); + const x = (id: string) => graph.nodes.find((n) => n.id === id)?.x ?? 0; + + // One vault node, legs stacked in one column to its right. + expect(graph.nodes.filter((n) => n.kind === 'source')).toHaveLength(1); + expect(graph.nodes.some((n) => n.kind === 'recipient')).toBe(false); + expect(x('source')).toBeLessThan(x('0xwrap')); + expect(x('0xwrap')).toBe(x('0xuniv2')); + expect(x('0xuniv2')).toBe(x('0xcow')); + expect(graph.width).toBeGreaterThan(0); + expect(graph.height).toBeGreaterThan(0); + }); + + it('renders a genuine external recipient when a leg transfers out', () => { + const externalDynamics: IFlowDynamics = { + steps: [ + { + address: '0xwrap', + index: 0, + state: 'done', + ins: [{ token: 'stETH', amount: 100, fidelity: 'real' }], + outs: [ + { + token: 'ETH', + amount: 5, + fidelity: 'real', + external: true, + to: '0xagent', + }, + ], + }, + ], + }; + const graph = buildFlowGraph({ + descriptor, + dynamics: externalDynamics, + }); + const dist = graph.edges.find((e) => e.kind === 'distributes'); + expect(dist?.target).toBe('recipient-0xagent'); + const recipientNode = graph.nodes.find( + (n) => n.id === 'recipient-0xagent', + ); + expect(recipientNode).toBeDefined(); + // Fed by exactly one leg → hung beside it as a satellite. + expect(recipientNode?.attachedTo).toBe('0xwrap'); + // An external transfer leaves the DAO → it must NOT count toward net. + const net = graph.nodes.find((n) => n.id === 'source')?.net; + expect(net?.some((e) => e.token === 'ETH')).toBe(false); + }); + + it('falls back to vault→leg feeds with no dynamics', () => { + const graph = buildFlowGraph({ descriptor }); + // One opaque feed per leg, no returns (outputs unknown). + expect(graph.edges).toHaveLength(3); + expect(graph.edges.every((e) => e.kind === 'feeds')).toBe(true); + expect(graph.edges.every((e) => e.fidelity === 'opaque')).toBe(true); + }); +}); diff --git a/src/modules/flow/canvas/buildFlowGraph.ts b/src/modules/flow/canvas/buildFlowGraph.ts new file mode 100644 index 0000000000..a3869b7686 --- /dev/null +++ b/src/modules/flow/canvas/buildFlowGraph.ts @@ -0,0 +1,396 @@ +/** + * buildFlowGraph — pure, data-driven construction of the canvas graph. + * + * Inputs: a static {@link IFlowMachineDescriptor} (structure, from the indexer + * taxonomy) + optional {@link IFlowDynamics} (live overlay or a replayed run). + * Output: positioned {@link IFlowGraph} the ported canvas renders 1:1. + * + * MODEL — hub-and-spoke around the ONE vault. A money machine is not a linear + * wrap→liquidity→buyback pipeline: it is a set of legs that each independently + * draw capital from the DAO vault and return proceeds to it, executed in + * strategy `index` order. So: + * - There is ONE vault node (the DAO) — the funding source AND where proceeds + * land. We never render the treasury twice. + * - `feeds` — vault → leg, one per token the leg draws (`ins`, the + * indexed VAULT_OUT edges). Stream slices carry `/epoch`. + * - `returns` — leg → vault, one per token the leg loops back (`outs` + * without `external`, the indexed VAULT_IN edges). + * - `distributes` — leg → an external recipient, for genuine outbound + * transfers (`outs` with `external`, indexed EXTERNAL edges). + * Nothing is inferred by token-matching across legs — every edge is a real, + * per-leg in/out movement from the dynamics. This stops fabricating leg→leg + * "pipeline" edges and stops hiding the value a leg retains in the vault (e.g. a + * wrap that pools wstETH back into the DAO). + * + * The vault node also carries `net` — the per-token delta to the DAO after the + * dispatch (returns − feeds) — so the canvas can state how much actually lands. + * + * NO HARDCODE: nothing knows about Lido/stETH/a fixed leg count. With no + * dynamics (a never-run flow) it falls back to vault → each leg `feeds` edges so + * the structure still reads. + */ + +import type { + FlowRuntimeState, + IFlowDynamics, + IFlowGraph, + IFlowGraphEdge, + IFlowGraphNode, + IFlowMachineDescriptor, + IFlowNetEntry, + IFlowNodeOutput, + IFlowStepDynamics, + IFlowSubInput, +} from './flowGraphTypes'; +import { layoutFlowGraph } from './layoutFlowGraph'; +import { outputsSettleAsync } from './primitiveRegistry'; + +const STRATEGY_W = 288; +const SOURCE_W = 252; +const RECIPIENT_W = 200; + +const STRATEGY_HEADER_H = 60; +const STRATEGY_BADGE_H = 28; +// Sub-input chips stack up to 3 rows (title / description / live values), so +// reserve enough vertical space that legs don't crowd the one below. +const STRATEGY_INPUT_H = 74; +const SOURCE_BASE_H = 72; +const SOURCE_BALANCE_H = 32; +const SOURCE_NET_H = 28; +/** Vertical room reserved per hub spoke (plus a top/bottom pad) so the vault's + * per-leg in/out anchors fan out with breathing space. The card is rendered at + * this full height (see EndpointCard), so anchors always land on it. */ +const SOURCE_SPOKE_H = 34; +const SOURCE_SPOKE_PAD = 56; +const RECIPIENT_H = 112; + +/** A token is "moving" along an edge when the producing step is active and not + * blocked. */ +const isActive = (state: FlowRuntimeState): boolean => + state === 'firing' || state === 'accumulating' || state === 'done'; + +/** Below this (in display token units) an amount is dust — a leg "firing" on it + * (e.g. wrapping 1 wei of leftover stETH) moved nothing meaningful. `null` = + * opaque/pending, which is a genuine movement awaiting settlement. */ +export const MEANINGFUL_AMOUNT_EPS = 1e-9; +const isMeaningful = (amount: number | null | undefined): boolean => + amount == null || Math.abs(amount) > MEANINGFUL_AMOUNT_EPS; + +// Extra height for the compact params line shown under the header when a +// strategy carries config params (slippage, target token, …). +const STRATEGY_PARAMS_H = 18; + +const strategyHeight = ( + inputCount: number, + hasBadge: boolean, + hasParams: boolean, +): number => + STRATEGY_HEADER_H + + (hasBadge ? STRATEGY_BADGE_H : 0) + + (hasParams ? STRATEGY_PARAMS_H : 0) + + inputCount * STRATEGY_INPUT_H + + (inputCount > 0 ? 6 : 0); + +export interface IBuildFlowGraphParams { + descriptor: IFlowMachineDescriptor; + /** Live overlay (from the RPC snapshot) or a replayed run; null = structure + * only. */ + dynamics?: IFlowDynamics | null; +} + +interface IStepView { + step: IFlowMachineDescriptor['steps'][number]; + dyn: IFlowStepDynamics | undefined; +} + +export const buildFlowGraph = (params: IBuildFlowGraphParams): IFlowGraph => { + const { descriptor, dynamics } = params; + + const dynByAddress = new Map(); + const dynByIndex = new Map(); + for (const step of dynamics?.steps ?? []) { + if (step.address) { + dynByAddress.set(step.address.toLowerCase(), step); + } + dynByIndex.set(step.index, step); + } + const getDyn = ( + address: string, + index: number, + ): IFlowStepDynamics | undefined => + dynByAddress.get(address.toLowerCase()) ?? dynByIndex.get(index); + + const steps = [...descriptor.steps].sort((a, b) => a.index - b.index); + const stepViews: IStepView[] = steps.map((step) => ({ + step, + dyn: getDyn(step.address, step.index), + })); + const hasDynamics = stepViews.some( + (s) => (s.dyn?.outs?.length ?? 0) > 0 || s.dyn?.ins != null, + ); + + /* ---- nodes -------------------------------------------------------- */ + const nodes: IFlowGraphNode[] = []; + + // Strategy legs, in execution order. + for (const { step, dyn } of stepViews) { + const inputs: IFlowSubInput[] = step.inputs.map((input, i) => { + const reading = dyn?.inputReadings?.[i]; + return { + role: input.role, + kind: input.kind, + label: input.label, + note: input.note, + token: reading?.token, + reading: reading?.reading, + detail: reading?.detail, + status: reading?.status, + epoch: reading?.epoch, + epochLength: reading?.epochLength, + }; + }); + // A leg the simulator reports as active but whose every in/out is dust + // (e.g. wrapping 1 wei of leftover stETH) moved nothing — show it idle + // with a no-op badge instead of a misleading "Firing 0". + const hasFlows = + (dyn?.ins?.length ?? 0) > 0 || (dyn?.outs?.length ?? 0) > 0; + const movesAnything = + (dyn?.ins ?? []).some((i) => isMeaningful(i.amount)) || + (dyn?.outs ?? []).some((o) => isMeaningful(o.amount)); + const rawState: FlowRuntimeState = dyn?.state ?? 'idle'; + const noOp = isActive(rawState) && hasFlows && !movesAnything; + const state: FlowRuntimeState = noOp ? 'idle' : rawState; + const badge = noOp ? (dyn?.badge ?? 'no budget') : dyn?.badge; + nodes.push({ + id: step.address, + kind: 'strategy', + address: step.address, + index: step.index, + title: step.label, + subtitle: step.subtitle, + primitiveKind: step.kind, + state, + badge, + inputs, + params: step.params, + x: 0, + y: 0, + w: STRATEGY_W, + h: strategyHeight( + inputs.length, + badge != null, + (step.params?.length ?? 0) > 0, + ), + }); + } + + /* ---- edges (real per-leg in/out movements) ----------------------- */ + const edges: IFlowGraphEdge[] = []; + const sourceId = 'source'; + const recipientId = descriptor.recipient + ? `recipient-${descriptor.recipient.address}` + : 'recipient'; + let hasExternal = false; + const externalProducers = new Set(); + const vaultLabel = descriptor.source.label; + const recipientLabel = descriptor.recipient?.label ?? 'Recipient'; + const outputsByStep = new Map(); + const net = new Map(); + const bumpNet = (token: string, signed: number, opaque: boolean): void => { + const entry = net.get(token) ?? { delta: 0, opaque: false }; + entry.delta += signed; + entry.opaque = entry.opaque || opaque; + net.set(token, entry); + }; + + if (hasDynamics) { + for (const { step, dyn } of stepViews) { + if (!dyn) { + continue; + } + const state = dyn.state ?? 'idle'; + const blocked = dyn.blocked ?? state === 'blocked'; + const flowingFeed = isActive(state) && !blocked; + const flowingOut = + (state === 'firing' || state === 'done') && !blocked; + + // feeds: vault → leg, one per drawn token (deduped upstream). A + // dust / zero amount (e.g. wrapping 1 wei of leftover stETH) draws + // no edge at all — no misleading "0" line for a leg that no-ops. + (dyn.ins ?? []).forEach((inflow, i) => { + if (!isMeaningful(inflow.amount)) { + return; + } + edges.push({ + id: `feed-${step.address}-${inflow.token}-${i}`, + source: sourceId, + target: step.address, + kind: 'feeds', + token: inflow.token, + amount: inflow.amount, + fidelity: inflow.fidelity, + perEpoch: inflow.perEpoch, + flowing: flowingFeed && isMeaningful(inflow.amount), + blocked, + }); + if (inflow.amount != null) { + bumpNet(inflow.token, -inflow.amount, false); + } else { + bumpNet(inflow.token, 0, true); + } + }); + + // returns / distributes: leg → vault (loop) or → external recipient. + // Dust / zero outputs draw no edge (and no inspector row) either. + // A leg whose outputs settle out-of-band (e.g. CoW buyback fill) + // tags those edges `settle`: they render in a distinct phase and + // don't animate as part of the dispatch "now". An explicit per-flow + // trigger (from indexed `pending`) wins over the kind default. + const settlesAsync = outputsSettleAsync(step.kind); + const stepOutputs: IFlowNodeOutput[] = []; + (dyn.outs ?? []).forEach((outflow, i) => { + if (!isMeaningful(outflow.amount)) { + return; + } + const external = outflow.external === true; + if (external) { + hasExternal = true; + externalProducers.add(step.address); + } + const trigger = + outflow.trigger ?? (settlesAsync ? 'settle' : 'dispatch'); + const settles = trigger === 'settle'; + edges.push({ + id: `out-${step.address}-${outflow.token}-${i}`, + source: step.address, + target: external ? recipientId : sourceId, + kind: external ? 'distributes' : 'returns', + token: outflow.token, + amount: outflow.amount, + fidelity: outflow.fidelity, + perEpoch: outflow.perEpoch, + // Settle-phase outputs don't animate as the dispatch "now". + flowing: + flowingOut && isMeaningful(outflow.amount) && !settles, + blocked, + trigger, + }); + stepOutputs.push({ + token: outflow.token, + amount: outflow.amount, + fidelity: outflow.fidelity, + toVault: !external, + toLabel: external ? recipientLabel : vaultLabel, + }); + // Proceeds looped back to the vault count toward net; genuine + // external transfers leave the DAO, so they don't. + if (!external) { + if (outflow.amount != null) { + bumpNet(outflow.token, outflow.amount, false); + } else { + bumpNet(outflow.token, 0, true); + } + } + }); + if (stepOutputs.length > 0) { + outputsByStep.set(step.address, stepOutputs); + } + } + for (const node of nodes) { + const outputs = outputsByStep.get(node.id); + if (outputs) { + node.outputs = outputs; + } + } + } else { + // Structure-only fallback: vault funds each leg (unknown amounts). + for (const { step } of stepViews) { + edges.push({ + id: `feed-${step.address}`, + source: sourceId, + target: step.address, + kind: 'feeds', + fidelity: 'opaque', + amount: null, + flowing: false, + }); + } + } + + // Vault (DAO) node — the single funding source AND where proceeds land. + const balances = dynamics?.balances ?? []; + const netEntries: IFlowNetEntry[] = hasDynamics + ? [...net.entries()] + .filter( + ([, v]) => + Math.abs(v.delta) > MEANINGFUL_AMOUNT_EPS || v.opaque, + ) + .map(([token, v]) => ({ + token, + delta: v.delta, + opaque: v.opaque || undefined, + })) + : []; + // The vault is the hub: every feeds/returns edge attaches a spoke to it, so + // grow the card tall enough to fan those anchors out (per-leg clusters with + // gaps), not just to fit its balance/net content. + const contentH = + SOURCE_BASE_H + + balances.length * SOURCE_BALANCE_H + + netEntries.length * SOURCE_NET_H; + const spokes = edges.filter( + (e) => e.source === sourceId || e.target === sourceId, + ).length; + const spokeH = spokes > 0 ? SOURCE_SPOKE_PAD + spokes * SOURCE_SPOKE_H : 0; + nodes.push({ + id: sourceId, + kind: 'source', + address: descriptor.source.address, + title: descriptor.source.label, + state: 'idle', + inputs: [], + balances, + net: netEntries.length > 0 ? netEntries : undefined, + x: 0, + y: 0, + w: SOURCE_W, + h: Math.max(140, contentH, spokeH), + }); + + // External recipient — only when a leg genuinely sends out of the DAO. When + // exactly one leg feeds it, hang it beside that leg as a satellite; when + // several do, it stays a shared node (no `attachedTo`). + if (hasExternal && descriptor.recipient) { + const node = buildRecipientNode(recipientId, descriptor.recipient); + if (externalProducers.size === 1) { + node.attachedTo = [...externalProducers][0]; + } + nodes.push(node); + } + + /* ---- layout ------------------------------------------------------- */ + const result = layoutFlowGraph(nodes, edges); + return { + nodes: result.nodes, + edges, + width: result.width, + height: result.height, + runId: dynamics?.runId ?? null, + }; +}; + +const buildRecipientNode = ( + id: string, + recipient: IFlowMachineDescriptor['recipient'], +): IFlowGraphNode => ({ + id, + kind: 'recipient', + address: recipient?.address, + title: recipient?.label ?? 'Recipient', + state: 'idle', + inputs: [], + x: 0, + y: 0, + w: RECIPIENT_W, + h: RECIPIENT_H, +}); diff --git a/src/modules/flow/canvas/flowDescriptor.ts b/src/modules/flow/canvas/flowDescriptor.ts new file mode 100644 index 0000000000..1090a3efb6 --- /dev/null +++ b/src/modules/flow/canvas/flowDescriptor.ts @@ -0,0 +1,161 @@ +/** + * Build a generic {@link IFlowMachineDescriptor} (static structure) from the + * indexer-derived domain types. This is the generalization seam: the canvas + * never reads `IFlowOrchestrator` directly, only the normalized descriptor, so + * any flow shape maps through the same path. + * + * NO HARDCODE: every field comes from the generic taxonomy on + * `IFlowEmbeddedStrategy` (`kind`, `index`, `budget.kind`, `gate.kind`, + * `epochProvider`) — never from a token symbol or DAO address. Labels resolve + * through {@link primitiveRegistry}. + */ + +import type { IFlowEmbeddedStrategy, IFlowOrchestrator } from '../types'; +import type { + IFlowMachineDescriptor, + IFlowStepDescriptor, + IFlowStrategyParam, + IFlowSubInputDescriptor, +} from './flowGraphTypes'; +import { + getBudgetDisplay, + getEpochDisplay, + getGateDisplay, + getStrategyDisplay, +} from './primitiveRegistry'; + +export interface IToDescriptorOptions { + /** Label for the funding vault (e.g. the DAO name). */ + sourceLabel: string; + sourceAddress?: string; + /** Terminal recipient hint, resolved from real recipient data by the caller. */ + recipient?: { address: string; label: string }; + /** Optional per-strategy display params (slippage, target token, …), keyed + * by lowercased strategy address. Sourced outside the indexer taxonomy + * (e.g. the live topology) by the caller; merged onto the matching step. */ + paramsByAddress?: Map; +} + +/** Humanize an epoch-denominated reserve into a duration when the epoch length + * is known (e.g. 168 epochs × 1h = "7d"); falls back to an epoch count. */ +const formatFloor = ( + floorEpochs?: string, + epochLengthSeconds?: number, +): string | undefined => { + if (floorEpochs == null) { + return undefined; + } + const floor = Number(floorEpochs); + if (!Number.isFinite(floor) || floor <= 0) { + return undefined; + } + if (epochLengthSeconds && epochLengthSeconds > 0) { + const seconds = floor * epochLengthSeconds; + const days = Math.round(seconds / 86_400); + if (days >= 1) { + return `${days}d floor`; + } + const hours = Math.round(seconds / 3600); + if (hours >= 1) { + return `${hours}h floor`; + } + } + return `${floor.toLocaleString('en-US')}-epoch floor`; +}; + +const buildStepInputs = ( + strategy: IFlowEmbeddedStrategy, +): IFlowSubInputDescriptor[] => { + const inputs: IFlowSubInputDescriptor[] = []; + const epochLengthSeconds = strategy.epochProvider?.epochLength + ? Number(strategy.epochProvider.epochLength) + : undefined; + + if (strategy.budget) { + const display = getBudgetDisplay(strategy.budget.kind); + const noteParts = [ + formatFloor(strategy.budget.floorEpochs, epochLengthSeconds), + strategy.budget.targetEpoch + ? `target epoch ${Number(strategy.budget.targetEpoch).toLocaleString('en-US')}` + : undefined, + ].filter((part): part is string => part != null); + inputs.push({ + role: 'budget', + kind: strategy.budget.kind, + label: display.label, + note: noteParts.length > 0 ? noteParts.join(' · ') : undefined, + }); + } + + if (strategy.gate) { + const display = getGateDisplay(strategy.gate.kind); + inputs.push({ + role: 'gate', + kind: strategy.gate.kind, + label: display.label, + }); + } + + if (strategy.epochProvider) { + const display = getEpochDisplay(); + const seconds = epochLengthSeconds; + const note = + seconds && seconds > 0 + ? `epoch length ${seconds >= 3600 ? `${Math.round(seconds / 3600)}h` : `${seconds}s`}` + : undefined; + inputs.push({ + role: 'epoch', + kind: 'epoch', + label: display.label, + note, + }); + } + + return inputs; +}; + +const toStep = (strategy: IFlowEmbeddedStrategy): IFlowStepDescriptor => { + const display = getStrategyDisplay(strategy.kind); + return { + index: strategy.index, + address: strategy.address, + kind: strategy.kind, + // Prefer the embedded strategy's own label when present, else registry. + label: strategy.label || display.label, + subtitle: display.subtitle, + paused: strategy.paused, + inputs: buildStepInputs(strategy), + }; +}; + +/** + * Normalize a multi-dispatch orchestrator's embedded strategies into a + * descriptor. Returns `null` when the orchestrator has no embedded strategies + * (e.g. a legacy multi-router that fans out to separate plugin policies — a + * later increment will derive a descriptor from its `chain`). + */ +export const toFlowMachineDescriptor = ( + orchestrator: IFlowOrchestrator, + options: IToDescriptorOptions, +): IFlowMachineDescriptor | null => { + const embedded = orchestrator.embeddedStrategies; + if (embedded == null || embedded.length === 0) { + return null; + } + const steps = [...embedded] + .sort((a, b) => a.index - b.index) + .map((strategy) => { + const step = toStep(strategy); + const params = options.paramsByAddress?.get( + strategy.address.toLowerCase(), + ); + return params && params.length > 0 ? { ...step, params } : step; + }); + + return { + id: orchestrator.id, + source: { address: options.sourceAddress, label: options.sourceLabel }, + steps, + recipient: options.recipient, + }; +}; diff --git a/src/modules/flow/canvas/flowDynamics.ts b/src/modules/flow/canvas/flowDynamics.ts new file mode 100644 index 0000000000..e2961441a8 --- /dev/null +++ b/src/modules/flow/canvas/flowDynamics.ts @@ -0,0 +1,330 @@ +/** + * Live dynamics producer — projects an LMM `StatusSnapshot` (RPC reads + + * `simulate()` of the next dispatch) onto the generic {@link IFlowDynamics} + * overlay that {@link buildFlowGraph} consumes. + * + * This is a generalized overlay-by-address, NOT an LMM-specific descriptor: + * per-step state, budget/gate/epoch readings, and the per-step token in/out + * flows are all read from the snapshot and matched to the descriptor by + * strategy address. The edge topology itself is still INFERRED by + * `buildFlowGraph` from those token flows — nothing here declares edges or + * hard-codes token symbols. When the indexer surfaces an `OrchestratorSnapshot` + * the same shape is produced from indexed data instead of RPC. + */ + +import type { Address } from 'viem'; +import type { + FlowGraph, + Provenance, + Step, + TokenInfo, +} from '@/shared/lidoPreview'; +import type { StatusSnapshot } from '../components/lidoMoneyMachine/useStatus'; +import type { + FlowFidelity, + FlowRuntimeState, + IFlowDynamics, + IFlowEdgeFlow, + IFlowMachineDescriptor, + IFlowStepDynamics, + IFlowSubInputReading, + IFlowVaultBalance, +} from './flowGraphTypes'; + +const tokenSymbol = (token: TokenInfo): string => + token.symbol ?? `${token.address.slice(0, 6)}…`; + +/** Scale a base-unit amount to a display number (token decimals, fallback 18). */ +const toNumber = (amount: bigint, decimals: number | null): number => + Number(amount) / 10 ** (decimals ?? 18); + +/** Format an oracle price/threshold (quote-token scale, 6dp) as a whole-dollar + * string, e.g. 3_200_000_000n → "3,200". */ +const fmtPrice = (value: bigint): string => + (Number(value) / 1e6).toLocaleString('en-US', { + maximumFractionDigits: 0, + }); + +const fidelityOf = (p: Provenance): FlowFidelity => { + if (p === 'opaque' || p === 'downstream-of-opaque') { + return 'opaque'; + } + if (p === 'estimated-via-quoter' || p === 'estimated-via-oracle') { + return 'estimated'; + } + return 'real'; +}; + +/** An amount is only shown when its provenance is knowable; opaque → null. */ +const amountFor = ( + amount: bigint, + decimals: number | null, + fidelity: FlowFidelity, +): number | null => (fidelity === 'opaque' ? null : toNumber(amount, decimals)); + +/** Compact a simulator `reason` to a short badge phrase. */ +const compactReason = (reason: string | undefined): string | undefined => { + if (!reason) { + return undefined; + } + if (reason.startsWith('oracle stale')) { + return 'oracle stale'; + } + if (reason.startsWith('same epoch')) { + return 'already dispatched'; + } + if (reason === 'gate closed') { + return 'gate closed'; + } + if (reason.startsWith('pool ratio')) { + return 'pool drift'; + } + if (reason.includes('budget') && reason.includes('0')) { + return 'no budget'; + } + return reason.length > 32 ? `${reason.slice(0, 29)}…` : reason; +}; + +const stateForStep = (step: Step | undefined): FlowRuntimeState => { + if (!step) { + return 'idle'; + } + switch (step.status) { + case 'executed': + return 'firing'; + case 'skipped-paused': + return 'skipped'; + case 'no-op': + return step.reason === 'gate closed' ? 'blocked' : 'idle'; + default: + return 'idle'; + } +}; + +/** + * Every token the vault gains back from this step (its net products, looped back + * to the DAO), plus any genuine outbound transfer to a non-vault address + * (flagged `external`). One arrow per output — nothing collapsed or hidden. + */ +const outFlowsForStep = (step: Step): IFlowEdgeFlow[] => { + const consumedAddrs = new Set( + step.externalCalls + .flatMap((c) => c.consumes) + .map((c) => c.token.address.toLowerCase()), + ); + const outs: IFlowEdgeFlow[] = []; + const seen = new Set(); + for (const produce of step.externalCalls.flatMap((c) => c.produces)) { + // A token that is also consumed this step is an intermediate, not a net + // product back to the vault — skip it. + if (consumedAddrs.has(produce.token.address.toLowerCase())) { + continue; + } + const symbol = tokenSymbol(produce.token); + if (seen.has(symbol)) { + continue; + } + seen.add(symbol); + const fidelity = fidelityOf(produce.provenance); + outs.push({ + token: symbol, + amount: amountFor(produce.amount, produce.token.decimals, fidelity), + fidelity, + }); + } + if (outs.length > 0) { + return outs; + } + // Transfer / burn / epoch-transfer strategies: the outbound transfer is a + // genuine external distribution out of the DAO. + const dao = step.before.dao.toLowerCase(); + const outbound = step.transfers.find((t) => t.to.toLowerCase() !== dao); + if (outbound) { + const fidelity = fidelityOf(outbound.provenance); + return [ + { + token: tokenSymbol(outbound.token), + amount: amountFor( + outbound.amount, + outbound.token.decimals, + fidelity, + ), + fidelity, + external: true, + to: outbound.to, + }, + ]; + } + return []; +}; + +/** Tokens this step consumes (budget + external-call consumes), deduped. */ +const inFlowsForStep = ( + step: Step, + streamTokenSymbol: string | undefined, +): IFlowEdgeFlow[] => { + const ins: IFlowEdgeFlow[] = []; + const push = (token: TokenInfo, amount: bigint, provenance: Provenance) => { + const symbol = tokenSymbol(token); + if (ins.some((i) => i.token === symbol)) { + return; + } + const fidelity = fidelityOf(provenance); + ins.push({ + token: symbol, + amount: amountFor(amount, token.decimals, fidelity), + fidelity, + perEpoch: symbol === streamTokenSymbol, + }); + }; + // External-call `consumes` are what the leg ACTUALLY draws — take them + // first. The budget is only the allowance/ceiling: a full LDO budget can be + // 11k while UniV2 only pairs ~2.3k against the streamed wstETH (the binding + // side) and the rest stays in the vault. Showing the budget as the draw + // overstated the spend and made the net-to-DAO wildly negative. + for (const call of step.externalCalls) { + for (const c of call.consumes) { + push(c.token, c.amount, c.provenance); + } + } + // Budget token only if no external call already drew it (transfer / burn / + // epoch-transfer / CoW presign have no `consumes`, so the budget IS the draw). + if (step.budget) { + push(step.budget.token, step.budget.amount, step.budget.provenance); + } + return ins; +}; + +/** Build the per-input live readings aligned to a descriptor step's inputs. */ +const readingsForStep = ( + snapshot: StatusSnapshot, + descriptorStep: IFlowMachineDescriptor['steps'][number], +): IFlowSubInputReading[] => + descriptorStep.inputs.map((input) => { + if (input.role === 'budget') { + const budget = snapshot.budgets.find( + (b) => + b.strategyAddress.toLowerCase() === + descriptorStep.address.toLowerCase(), + ); + if (!budget) { + return {}; + } + const reading = { + token: tokenSymbol(budget.token), + reading: toNumber(budget.amount, budget.token.decimals), + }; + // Stream-until budgets meter a per-epoch slice; give that slice + // context with the live burn-down runway (epochs left to the + // target, or the floored-drain note once the target is passed). The + // `reading` above already IS the per-epoch slice (`budget()`). + const stream = snapshot.stream; + if (input.kind === 'streamUntil' && stream) { + const remaining = Number(stream.remaining); + const detail = + remaining > 0 + ? `streams a slice / epoch · ${remaining} epoch${remaining === 1 ? '' : 's'} to target` + : `past target · capped at balance ÷ ${stream.floorEpochs}/epoch`; + return { ...reading, detail }; + } + return reading; + } + if (input.role === 'gate') { + const gate = snapshot.gate; + if (!gate) { + return {}; + } + // Surface the actual numbers so you can see WHAT must happen: the + // live oracle price vs the floor it must clear. Quote scale = the + // gate's quote-token decimals (the LMM oracle quotes in USDC, 6dp — + // matching the vendored TopologyView's `formatAmount(price, 6, 2)`). + const floor = `$${fmtPrice(gate.threshold)}`; + const now = gate.price != null ? `$${fmtPrice(gate.price)}` : '—'; + // The gate fails for TWO distinct reasons: price below floor, OR the + // oracle reading is too old. If the live price clears the floor yet + // the gate still fails, it's staleness — say so; otherwise show the + // now-vs-floor comparison (the numbers make below-floor self-evident). + const belowFloor = + gate.price != null && gate.price < gate.threshold; + const detail = + gate.passes || belowFloor + ? `now ${now} · floor ${floor}` + : `oracle stale · floor ${floor}`; + return { + status: gate.passes ? 'open' : 'closed', + detail, + }; + } + // epoch + const epoch = snapshot.stream?.currentEpoch; + return epoch != null ? { epoch: Number(epoch) } : {}; + }); + +export interface IToLiveDynamicsParams { + descriptor: IFlowMachineDescriptor; + snapshot: StatusSnapshot; +} + +/** + * Project a {@link StatusSnapshot} onto {@link IFlowDynamics} for the given + * descriptor. Per-input readings come from the live RPC reads; per-step + * state + token in/out flows come from the simulated next dispatch (when + * available) so the canvas can infer edges and animate active legs. + */ +export const toLiveDynamics = ( + params: IToLiveDynamicsParams, +): IFlowDynamics => { + const { descriptor, snapshot } = params; + const flow: FlowGraph | null = snapshot.nextDispatch; + const streamTokenSymbol = snapshot.stream + ? tokenSymbol(snapshot.stream.token) + : undefined; + + const stepByAddress = new Map(); + for (const step of flow?.steps ?? []) { + stepByAddress.set(step.strategyRef.address.toLowerCase(), step); + } + + const steps: IFlowStepDynamics[] = descriptor.steps.map( + (descriptorStep) => { + const step = stepByAddress.get( + descriptorStep.address.toLowerCase(), + ); + const state = stateForStep(step); + const blocked = state === 'blocked'; + + let badge: string | undefined; + if (step) { + if (blocked || step.status === 'no-op') { + badge = compactReason(step.reason); + } else if (state === 'skipped') { + badge = 'paused'; + } + } + + return { + address: descriptorStep.address, + index: descriptorStep.index, + state, + badge, + blocked, + inputReadings: readingsForStep(snapshot, descriptorStep), + outs: step ? outFlowsForStep(step) : undefined, + ins: step ? inFlowsForStep(step, streamTokenSymbol) : undefined, + skipReason: step?.reason, + }; + }, + ); + + const balances: IFlowVaultBalance[] = snapshot.balances + .filter((b) => b.amount > 0n) + .map((b) => ({ + token: tokenSymbol(b.token), + amount: toNumber(b.amount, b.token.decimals), + })); + + return { runId: null, steps, balances }; +}; + +/** Narrow re-export to keep the producer's address typing honest at call sites. */ +export type { Address }; diff --git a/src/modules/flow/canvas/flowGraphTypes.ts b/src/modules/flow/canvas/flowGraphTypes.ts new file mode 100644 index 0000000000..64e038df64 --- /dev/null +++ b/src/modules/flow/canvas/flowGraphTypes.ts @@ -0,0 +1,333 @@ +/** + * Generic, data-driven model for the Money Machine flow canvas. + * + * NO HARDCODE: nothing here knows about Lido, stETH, or a fixed three-strategy + * pipeline. The graph is produced by {@link buildFlowGraph} from a normalized + * {@link IFlowMachineDescriptor} (static structure, derived from the indexer's + * generic taxonomy) merged with optional live/replay {@link IFlowDynamics} + * (amounts, states). Any flow — router, claimer, or a multi-dispatch + * orchestrator with arbitrary strategies/tokens — renders correctly by feeding + * its descriptor + dynamics through the same path. + * + * The shape intentionally mirrors the vendored Claude Design "canvas" data + * (source + strategy nodes with hanging input "parts" + fidelity-aware edges) + * so the ported visual components consume it 1:1. + */ + +import type { FlowEmbeddedStrategyKind } from '../types'; + +/** What a node represents on the canvas. */ +export type FlowGraphNodeKind = 'source' | 'strategy' | 'recipient'; + +/** + * Edge semantics, used purely for styling/labelling. The flow is hub-and-spoke + * around the ONE funding vault (the DAO): every leg independently draws from the + * vault and returns proceeds to it. There are NO inferred leg→leg "pipeline" + * edges — that fabricated a chain the contract never executes and hid the value + * a leg retains in the vault. + * - `feeds` — vault → leg (a token the leg consumes this dispatch). + * - `returns` — leg → vault (proceeds looped back to the DAO). + * - `distributes` — leg → an external recipient (a genuine outbound transfer to + * a non-vault address). + */ +export type FlowGraphEdgeKind = 'feeds' | 'returns' | 'distributes'; + +/** + * How sure we are about an amount on an edge — drives solid/dashed/ghost + * styling and the `~` prefix. Mirrors the indexer provenance classes + * (deterministic/estimated/opaque) collapsed to what the UI needs. + */ +export type FlowFidelity = 'real' | 'estimated' | 'opaque'; + +/** Runtime state of a strategy node — drives colour, badge, pulsing. */ +export type FlowRuntimeState = + | 'idle' + | 'accumulating' + | 'firing' + | 'blocked' + | 'done' + | 'failed' + | 'skipped'; + +/** The role a hanging "part" plays beneath a strategy node. */ +export type FlowSubInputRole = 'budget' | 'gate' | 'epoch'; + +/** + * A hanging input "part" beneath a strategy node (budget / gate / epoch + * provider) — the N8N-style sub-node. Static fields come from the descriptor; + * live fields (`reading`, `status`, `detail`, `epoch`) are filled from + * dynamics. + */ +export interface IFlowSubInput { + role: FlowSubInputRole; + /** Generic kind from the indexer taxonomy, e.g. `full` | `streamUntil` | + * `required` (budget), `priceFloor` (gate). Never an LMM-specific value. */ + kind: string; + /** Human label resolved via the primitive registry. */ + label: string; + /** Live token symbol for `reading`, when the overlay supplies one. */ + token?: string; + /** Live numeric reading (e.g. budget amount available this epoch). */ + reading?: number | null; + /** Static note derived from config, e.g. "7d floor · target epoch 49,283". */ + note?: string; + /** Richer live detail, e.g. the gate's oracle price vs threshold + staleness. */ + detail?: string; + /** Gate open/closed (overlay-provided). */ + status?: 'open' | 'closed'; + /** Epoch-provider current epoch (overlay-provided). */ + epoch?: number; + /** Epoch length label, e.g. "1h". */ + epochLength?: string; +} + +export interface IFlowVaultBalance { + token: string; + amount: number; +} + +/** + * Net change to the DAO vault for one token after a dispatch: + * `delta = produced-back-to-vault − consumed-from-vault`. Surfaced on the vault + * node so the canvas answers "how much actually lands back in the DAO" — the + * retained value the old inferred-pipeline model used to hide. `opaque` marks a + * token whose net includes an unsettled (null) amount, so the delta is partial. + */ +export interface IFlowNetEntry { + token: string; + delta: number; + opaque?: boolean; +} + +/** One token a strategy leg produces, and where it lands. `toVault` = loops + * back to the DAO vault; otherwise `toLabel` names the external recipient. */ +export interface IFlowNodeOutput { + token: string; + amount: number | null; + fidelity: FlowFidelity; + toVault: boolean; + toLabel: string; +} + +/** + * A canvas node with layout geometry applied. Geometry (`x/y/w/h`) is assigned + * by {@link layoutFlowGraph}; everything else by {@link buildFlowGraph}. + */ +export interface IFlowGraphNode { + id: string; + kind: FlowGraphNodeKind; + address?: string; + /** Strategy order within the flow — drives the layout column. */ + index?: number; + title: string; + subtitle?: string; + /** Strategy primitive kind → icon/label via the registry. */ + primitiveKind?: FlowEmbeddedStrategyKind; + state: FlowRuntimeState; + /** Short status line under a strategy node (e.g. "blocked: gate closed"). */ + badge?: string; + /** Hanging "parts" beneath a strategy node. Empty for source/recipient. */ + inputs: IFlowSubInput[]; + /** Static config parameters (slippage, target token, …) for a strategy node, + * shown on the card + inspector. Copied from the descriptor step. */ + params?: IFlowStrategyParam[]; + /** Vault balances for the `source` node. */ + balances?: IFlowVaultBalance[]; + /** Net token deltas to the vault after a dispatch (vault `source` node). */ + net?: IFlowNetEntry[]; + /** Where a strategy leg sends each token it produces — the vault (loops + * back) or a named external recipient. Drives the inspector's output row. */ + outputs?: IFlowNodeOutput[]; + /** Recipient node only: when exactly one leg feeds it, the address of that + * leg — the layout hangs the recipient beside it as a satellite. */ + attachedTo?: string; + /** Layout geometry (px in the fixed-coordinate stage). */ + x: number; + y: number; + w: number; + h: number; +} + +export interface IFlowGraphEdge { + id: string; + source: string; + target: string; + kind: FlowGraphEdgeKind; + token?: string; + /** `null` = opaque/unknown amount — the UI renders "pending", never a + * fabricated number. */ + amount?: number | null; + fidelity: FlowFidelity; + /** Stream-style edge labelled "/epoch". */ + perEpoch?: boolean; + /** Animate a travelling token dot along this edge. */ + flowing?: boolean; + /** Render the edge as blocked (gate closed / would-not-fire). */ + blocked?: boolean; + /** When this movement happens — `settle` outputs (CoW fill) render in a + * distinct phase style and don't animate as part of the dispatch "now". */ + trigger?: FlowEdgeTrigger; + note?: string; +} + +export interface IFlowGraph { + nodes: IFlowGraphNode[]; + edges: IFlowGraphEdge[]; + /** Fixed-coordinate stage bounds the canvas auto-fits to. */ + width: number; + height: number; + /** Non-null when the graph is a historical run (replay), else live. */ + runId?: string | null; +} + +/* ------------------------------------------------------------------------- * + * Descriptor — the STATIC structure of a flow, normalized from the indexer's + * generic taxonomy (orchestrator embedded strategies / chain / leaf policy). + * Carries no amounts or live state. + * ------------------------------------------------------------------------- */ + +export interface IFlowSubInputDescriptor { + role: FlowSubInputRole; + kind: string; + label: string; + note?: string; +} + +/** A single display-ready config parameter for a strategy (e.g. "Max slippage" + * → "0.50%"). Generic label/value pairs so the canvas renders them without any + * per-strategy knowledge; producers decide what to surface. */ +export interface IFlowStrategyParam { + label: string; + value: string; +} + +export interface IFlowStepDescriptor { + index: number; + address: string; + kind: FlowEmbeddedStrategyKind; + label: string; + subtitle?: string; + paused: boolean; + inputs: IFlowSubInputDescriptor[]; + /** Optional static config parameters (slippage, target token, …) surfaced + * on the card/inspector. Empty/undefined when none are known. */ + params?: IFlowStrategyParam[]; +} + +export interface IFlowMachineDescriptor { + /** Flow (orchestrator/policy) id. */ + id: string; + source: { address?: string; label: string }; + steps: IFlowStepDescriptor[]; + recipient?: { address: string; label: string }; +} + +/* ------------------------------------------------------------------------- * + * Dynamics — the LIVE or REPLAY layer. Generic shape so the producers + * (StatusSnapshot overlay for live, IFlowOrchestratorRun for replay) stay + * decoupled from buildFlowGraph. One entry per strategy step, matched to the + * descriptor by `address` (preferred) or `index`. + * ------------------------------------------------------------------------- */ + +/** When a flow materialises: at `dispatch()` time, or out-of-band later when the + * strategy's order settles (e.g. a CowSwap solver fill). Default `dispatch`. */ +export type FlowEdgeTrigger = 'dispatch' | 'settle'; + +export interface IFlowEdgeFlow { + token: string; + /** `null` for opaque outputs (e.g. LP minted, swap fill pre-settlement). */ + amount: number | null; + fidelity: FlowFidelity; + perEpoch?: boolean; + /** Output only: `true` when the token leaves to a genuine external recipient + * rather than looping back to the vault. Drives `distributes` vs `returns`. */ + external?: boolean; + /** Output only: the external recipient address, when known. */ + to?: string; + /** When this flow happens — `settle` for outputs that land out-of-band + * (CoW fill). Defaults to `dispatch` when unset. */ + trigger?: FlowEdgeTrigger; +} + +/** Live reading for one hanging input, positionally matched to the + * descriptor step's `inputs`. */ +export interface IFlowSubInputReading { + token?: string; + reading?: number | null; + status?: 'open' | 'closed'; + detail?: string; + epoch?: number; + epochLength?: string; +} + +export interface IFlowStepDynamics { + address: string; + index: number; + state: FlowRuntimeState; + badge?: string; + /** Per-input readings, aligned by position with the descriptor's inputs. */ + inputReadings?: IFlowSubInputReading[]; + /** Tokens this step produces — looped back to the vault (`external` unset) or + * sent to an external recipient (`external: true`). Multiple are allowed. */ + outs?: IFlowEdgeFlow[]; + /** Tokens this step draws from the vault this dispatch (vault-out). */ + ins?: IFlowEdgeFlow[]; + skipReason?: string; + blocked?: boolean; +} + +export interface IFlowDynamics { + /** Non-null when these dynamics represent a historical run (replay). */ + runId?: string | null; + steps: IFlowStepDynamics[]; + /** Source-vault balances overlay. */ + balances?: IFlowVaultBalance[]; +} + +/* ------------------------------------------------------------------------- * + * Indexed provenance graph — the app-side, decoupled normalisation of the + * indexer's `FlowStep` / `FlowEdge` / `SwapFill` entities. This is what + * {@link toIndexedDynamics} projects onto {@link IFlowDynamics}, replacing the + * heuristic reconstruction the dashboard mapper used to do. Amounts are already + * normalised to display numbers (or `null` when the provenance is opaque). + * ------------------------------------------------------------------------- */ + +/** Where an indexed edge sits relative to the funding vault. */ +export type FlowIndexedEdgeRole = 'vaultOut' | 'vaultIn' | 'external'; + +/** A normalised, provenance-tagged token movement attached to a step. */ +export interface IFlowIndexedEdge { + role: FlowIndexedEdgeRole; + token: string; + to: string; + /** `null` when opaque (amount not yet settled, e.g. LP pre-Mint). */ + amount: number | null; + fidelity: FlowFidelity; + perEpoch?: boolean; + pending: boolean; +} + +/** Normalised execution status of an indexed step. */ +export type FlowIndexedStatus = + | 'executed' + | 'noOp' + | 'skippedPaused' + | 'skippedGated' + | 'failed' + | 'opaque'; + +/** + * One projected leg of an indexed run/overview, matched to a descriptor step by + * `address` (preferred) or `index`. Carries the real, provenance-tagged edges + * the indexer settled — no calldata heuristics. + */ +export interface IFlowIndexedStep { + address: string; + index: number; + kind: FlowEmbeddedStrategyKind; + status: FlowIndexedStatus; + reason?: string; + /** True while any edge amount is still unsettled. */ + pending: boolean; + edges: IFlowIndexedEdge[]; +} diff --git a/src/modules/flow/canvas/layoutFlowGraph.ts b/src/modules/flow/canvas/layoutFlowGraph.ts new file mode 100644 index 0000000000..a2d006030e --- /dev/null +++ b/src/modules/flow/canvas/layoutFlowGraph.ts @@ -0,0 +1,150 @@ +/** + * Hub-and-spoke layout for the flow canvas. The model is fixed by intent (not + * inferred from edges): the ONE vault is the hub on the left, the strategy legs + * stack in execution order in the middle, and a genuine external recipient (if + * any) sits on the right. The vault and recipient are centred vertically against + * the legs' stack so every spoke reads as drawing-from / returning-to the hub. + * + * NO HARDCODE: columns come from node `kind` (`source` → `strategy` → + * `recipient`) and legs order by their strategy `index`, so any flow lays out + * the same way regardless of token symbols or leg count. Geometry (x/y/w/h) is + * computed from node sizes; the canvas auto-fits the resulting stage. + */ + +import type { IFlowGraphNode } from './flowGraphTypes'; + +const COL_GAP = 220; +const ROW_GAP = 80; +const PAD = 48; +/** Short gap between a leg and a recipient hung beside it (the "satellite"). */ +const ATTACH_GAP = 196; + +export interface ILayoutResult { + nodes: IFlowGraphNode[]; + width: number; + height: number; +} + +/** Column band a node lives in: vault hub, legs, external recipient. */ +const columnFor = (node: IFlowGraphNode): number => { + if (node.kind === 'source') { + return 0; + } + if (node.kind === 'recipient') { + return 2; + } + return 1; +}; + +export const layoutFlowGraph = ( + nodes: IFlowGraphNode[], + // Edges don't drive the hub layout, but the signature stays stable for + // callers (and a future topology-aware variant). + _edges: unknown, +): ILayoutResult => { + if (nodes.length === 0) { + return { nodes, width: 0, height: 0 }; + } + + // Recipients pinned to a single producing leg are laid out as satellites + // beside that leg, not in the recipient column. + const attached = nodes.filter( + (n) => n.kind === 'recipient' && n.attachedTo != null, + ); + const columnNodes = nodes.filter((n) => !attached.includes(n)); + + // Group into the fixed bands, dropping empty ones so the stage is tight + // (e.g. no free recipient → vault sits directly beside the legs). + const banded = new Map(); + for (const node of columnNodes) { + const c = columnFor(node); + const list = banded.get(c); + if (list) { + list.push(node); + } else { + banded.set(c, [node]); + } + } + const cols = [...banded.keys()].sort((a, b) => a - b); + const columns = cols.map((c) => banded.get(c) ?? []); + + // Within a column: legs ordered by index (top→bottom); others stable. + for (const column of columns) { + column.sort((a, b) => { + if (a.kind === 'strategy' && b.kind === 'strategy') { + return (a.index ?? 0) - (b.index ?? 0); + } + return 0; + }); + } + + const colWidth = columns.map((column) => + column.reduce((max, n) => Math.max(max, n.w), 0), + ); + const colHeight = columns.map((column) => + column.length === 0 + ? 0 + : column.reduce((sum, n) => sum + n.h, 0) + + (column.length - 1) * ROW_GAP, + ); + const stackHeight = Math.max(0, ...colHeight); + + // x per column (left edge), accumulating widths + gaps. + const colX: number[] = []; + let x = PAD; + for (let i = 0; i < columns.length; i += 1) { + colX.push(x); + x += (colWidth[i] ?? 0) + COL_GAP; + } + + const placed = new Map(); + columns.forEach((column, i) => { + const colTop = PAD + (stackHeight - (colHeight[i] ?? 0)) / 2; + const width = colWidth[i] ?? 0; + let y = colTop; + for (const node of column) { + // Centre each node horizontally within its column band. + const nodeX = (colX[i] ?? PAD) + (width - node.w) / 2; + placed.set(node.id, { x: nodeX, y }); + y += node.h + ROW_GAP; + } + }); + + // Place each attached recipient just to the right of its producing leg, + // top-aligned — the short `distributes` edge reads as the connector. + const byId = new Map(columnNodes.map((n) => [n.id, n])); + for (const recipient of attached) { + const leg = recipient.attachedTo + ? byId.get(recipient.attachedTo) + : undefined; + const legPos = leg ? placed.get(leg.id) : undefined; + if (leg && legPos) { + placed.set(recipient.id, { + x: legPos.x + leg.w + ATTACH_GAP, + y: legPos.y, + }); + } + } + + const laidOut = nodes.map((node) => { + const pos = placed.get(node.id); + return pos ? { ...node, x: pos.x, y: pos.y } : node; + }); + + // Bounds cover every placed node (attached satellites can extend past the + // last column / below a short column). + let maxRight = 0; + let maxBottom = 0; + for (const node of laidOut) { + const pos = placed.get(node.id); + if (!pos) { + continue; + } + maxRight = Math.max(maxRight, pos.x + node.w); + maxBottom = Math.max(maxBottom, pos.y + node.h); + } + const width = maxRight + PAD; + const height = Math.max(stackHeight + PAD * 2, maxBottom + PAD); + + return { nodes: laidOut, width, height }; +}; diff --git a/src/modules/flow/canvas/primitiveRegistry.ts b/src/modules/flow/canvas/primitiveRegistry.ts new file mode 100644 index 0000000000..504caaa3e2 --- /dev/null +++ b/src/modules/flow/canvas/primitiveRegistry.ts @@ -0,0 +1,137 @@ +/** + * Primitive registry — the SINGLE place that maps the indexer's generic + * primitive taxonomy (`Strategy.kind`, `Budget.kind`, `Gate.kind`, epoch) to + * display metadata (label, venue subtitle, icon). This is the only module + * allowed to "know" the set of primitive kinds. + * + * NO HARDCODE rule: keyed strictly by generic kind enums — never by DAO + * address, token symbol, or a specific flow. Supporting a new primitive is a + * one-line entry here; the canvas, inspector, history, and palette all read + * through these helpers, so nothing else needs to change. + * + * `icon` values are the names served by the workbench icon set + * ({@link MmIcon}); unknown kinds fall back to a neutral contract glyph. + */ + +import type { + FlowEmbeddedStrategyKind, + IFlowEmbeddedBudget, + IFlowEmbeddedGate, +} from '../types'; + +export interface IPrimitiveDisplay { + /** Primary node/row label, e.g. "Add Liquidity". */ + label: string; + /** Generic venue/mechanism subtitle (token-agnostic), e.g. "Uniswap V2". */ + subtitle?: string; + /** Icon name resolved by {@link MmIcon}. */ + icon: string; +} + +type StrategyKind = FlowEmbeddedStrategyKind; +type BudgetKind = IFlowEmbeddedBudget['kind']; +type GateKind = IFlowEmbeddedGate['kind']; + +const STRATEGY: Record = { + wrap: { label: 'Wrap', subtitle: 'Wrap token', icon: 'reload' }, + univ2Liquidity: { + label: 'Add Liquidity', + subtitle: 'Uniswap V2', + icon: 'droplet', + }, + gatedCowSwap: { + label: 'Buyback', + subtitle: 'CowSwap · gated', + icon: 'swap', + }, + cowSwap: { label: 'Swap', subtitle: 'CowSwap', icon: 'swap' }, + transfer: { label: 'Transfer', subtitle: 'Transfer', icon: 'withdraw' }, + epochTransfer: { + label: 'Epoch Transfer', + subtitle: 'Per-epoch transfer', + icon: 'withdraw', + }, + burn: { label: 'Burn', subtitle: 'Destroy tokens', icon: 'burn-assets' }, + unknown: { label: 'Strategy', icon: 'blockchain-smartcontract' }, +}; + +const BUDGET: Record = { + full: { label: 'Full Budget', icon: 'blockchain-wallet' }, + streamUntil: { label: 'Stream-Until Budget', icon: 'blockchain-wallet' }, + required: { label: 'Required Budget', icon: 'blockchain-wallet' }, + unknown: { label: 'Budget', icon: 'blockchain-wallet' }, +}; + +const GATE: Record = { + priceFloor: { label: 'Price-Floor Gate', icon: 'gate' }, + unknown: { label: 'Gate', icon: 'gate' }, +}; + +const EPOCH: IPrimitiveDisplay = { + label: 'Epoch Provider', + icon: 'clock', +}; + +const SOURCE: IPrimitiveDisplay = { + label: 'Vault source', + icon: 'blockchain-wallet', +}; +const RECIPIENT: IPrimitiveDisplay = { + label: 'Recipient', + icon: 'blockchain-block', +}; + +const FALLBACK: IPrimitiveDisplay = { + label: 'Component', + icon: 'blockchain-smartcontract', +}; + +// Strategies whose OUTPUTS land out-of-band (asynchronously) rather than at +// dispatch time — e.g. a CowSwap order is presigned during `dispatch()` but the +// buy-side fill only arrives later, when a solver settles it. The canvas renders +// such outputs as a distinct "settle" phase instead of folding them into the +// immediate dispatch flow. Keyed by generic kind — no per-flow hardcode; a leg's +// FEED (vault → leg) is always a dispatch-time commit regardless. +const OUTPUTS_SETTLE_ASYNC: Partial> = { + gatedCowSwap: true, + cowSwap: true, +}; + +/** Whether a strategy's produced outputs settle out-of-band (async). */ +export const outputsSettleAsync = (kind: string): boolean => + OUTPUTS_SETTLE_ASYNC[kind as StrategyKind] ?? false; + +/** Strategy display, falling back to the generic `unknown` entry. */ +export const getStrategyDisplay = (kind: string): IPrimitiveDisplay => + STRATEGY[kind as StrategyKind] ?? STRATEGY.unknown; + +/** Budget display, falling back to the generic `unknown` entry. */ +export const getBudgetDisplay = (kind: string): IPrimitiveDisplay => + BUDGET[kind as BudgetKind] ?? BUDGET.unknown; + +/** Gate display, falling back to the generic `unknown` entry. */ +export const getGateDisplay = (kind: string): IPrimitiveDisplay => + GATE[kind as GateKind] ?? GATE.unknown; + +export const getEpochDisplay = (): IPrimitiveDisplay => EPOCH; +export const getSourceDisplay = (): IPrimitiveDisplay => SOURCE; +export const getRecipientDisplay = (): IPrimitiveDisplay => RECIPIENT; + +/** + * Resolve display for any hanging sub-input by its role + kind. Used by the + * canvas sub-node chips and the inspector input rows. + */ +export const getSubInputDisplay = ( + role: 'budget' | 'gate' | 'epoch', + kind: string, +): IPrimitiveDisplay => { + if (role === 'budget') { + return getBudgetDisplay(kind); + } + if (role === 'gate') { + return getGateDisplay(kind); + } + return EPOCH; +}; + +export const getFallbackDisplay = (): IPrimitiveDisplay => FALLBACK; diff --git a/src/modules/flow/canvas/toIndexedDynamics.test.ts b/src/modules/flow/canvas/toIndexedDynamics.test.ts new file mode 100644 index 0000000000..cace3ebcc5 --- /dev/null +++ b/src/modules/flow/canvas/toIndexedDynamics.test.ts @@ -0,0 +1,260 @@ +import { buildFlowGraph } from './buildFlowGraph'; +import type { + IFlowDynamics, + IFlowIndexedStep, + IFlowMachineDescriptor, +} from './flowGraphTypes'; +import { mergeLiveOverlay, toIndexedDynamics } from './toIndexedDynamics'; + +/** + * Indexed steps mirror the live Hasura `FlowStep`/`FlowEdge` payload for the + * Lido Money Machine: WRAP (stETH→wstETH), UNIV2 (wstETH→LP, opaque pending), + * GATED_COWSWAP (wstETH→LDO, settled). Nothing here is hard-coded in the + * producer — every value comes from an edge. + */ +const indexedSteps: IFlowIndexedStep[] = [ + { + address: '0xwrap', + index: 0, + kind: 'wrap', + status: 'executed', + pending: false, + edges: [ + { + role: 'vaultOut', + token: 'stETH', + to: '0xwsteth', + amount: 100, + fidelity: 'real', + pending: false, + }, + { + role: 'vaultIn', + token: 'wstETH', + to: '0xdao', + amount: 80.9, + fidelity: 'real', + pending: false, + }, + ], + }, + { + address: '0xuniv2', + index: 1, + kind: 'univ2Liquidity', + status: 'executed', + pending: true, + edges: [ + { + role: 'vaultOut', + token: 'wstETH', + to: '0xpair', + amount: 40, + fidelity: 'real', + pending: false, + }, + { + role: 'vaultIn', + token: 'UNI-V2', + to: '0xdao', + amount: null, + fidelity: 'opaque', + pending: true, + }, + ], + }, + { + address: '0xcow', + index: 2, + kind: 'gatedCowSwap', + status: 'executed', + pending: false, + edges: [ + { + role: 'vaultOut', + token: 'wstETH', + to: '0xrelayer', + amount: 0.48, + fidelity: 'real', + pending: false, + }, + { + role: 'vaultIn', + token: 'LDO', + to: '0xdao', + amount: 100, + fidelity: 'real', + pending: false, + }, + ], + }, +]; + +describe('toIndexedDynamics', () => { + it('projects each step onto ins (vault-out) + outs (vault-in) with real amounts', () => { + const dynamics = toIndexedDynamics({ + steps: indexedSteps, + runId: 'run-1', + }); + + expect(dynamics.runId).toBe('run-1'); + const wrap = dynamics.steps.find((s) => s.address === '0xwrap'); + expect(wrap?.state).toBe('done'); + expect(wrap?.ins).toEqual([ + { + token: 'stETH', + amount: 100, + fidelity: 'real', + perEpoch: undefined, + }, + ]); + expect(wrap?.outs).toEqual([ + { + token: 'wstETH', + amount: 80.9, + fidelity: 'real', + perEpoch: undefined, + }, + ]); + }); + + it('routes outputs landing outside the DAO to an external recipient', () => { + // LP minted to the Lido Agent (0x3e40), not the operational DAO (0xdao): + // the indexer tags it vaultIn, but with the DAO address known it is + // shown as external (matches the live simulation + the contract). + const steps: IFlowIndexedStep[] = [ + { + address: '0xuniv2', + index: 0, + kind: 'univ2Liquidity', + status: 'executed', + pending: false, + edges: [ + { + role: 'vaultOut', + token: 'wstETH', + to: '0xpair', + amount: 1, + fidelity: 'real', + pending: false, + }, + { + role: 'vaultIn', + token: 'UNI-V2', + to: '0x3e40', + amount: 6.32, + fidelity: 'real', + pending: false, + }, + ], + }, + ]; + const dyn = toIndexedDynamics({ steps, daoAddress: '0xdao' }); + const lp = dyn.steps[0]?.outs?.[0]; + expect(lp?.external).toBe(true); + expect(lp?.to).toBe('0x3e40'); + + // Same edge with the matching DAO address loops back to the vault. + const loop = toIndexedDynamics({ steps, daoAddress: '0x3e40' }); + expect(loop.steps[0]?.outs?.[0]?.external).toBeUndefined(); + }); + + it('keeps opaque amounts as null (never fabricated)', () => { + const dynamics = toIndexedDynamics({ steps: indexedSteps }); + const univ2 = dynamics.steps.find((s) => s.address === '0xuniv2'); + expect(univ2?.outs).toEqual([ + { + token: 'UNI-V2', + amount: null, + fidelity: 'opaque', + perEpoch: undefined, + }, + ]); + }); + + it('feeds buildFlowGraph so it draws real vault-out/vault-in edges', () => { + const descriptor: IFlowMachineDescriptor = { + id: 'orch-1', + source: { label: 'Vault', address: '0xdao' }, + recipient: { address: '0xagent', label: 'Agent' }, + steps: indexedSteps.map((s) => ({ + index: s.index, + address: s.address, + kind: s.kind, + label: s.kind, + paused: false, + inputs: [], + })), + }; + const graph = buildFlowGraph({ + descriptor, + dynamics: toIndexedDynamics({ steps: indexedSteps }), + }); + + // Each leg draws wstETH straight from the vault, and no edge runs + // between two legs — only vault↔leg edges exist. + expect( + graph.edges.every( + (e) => e.source === 'source' || e.target === 'source', + ), + ).toBe(true); + const cowFeed = graph.edges.find( + (e) => e.kind === 'feeds' && e.target === '0xcow', + ); + expect(cowFeed?.source).toBe('source'); + expect(cowFeed?.token).toBe('wstETH'); + // CoW's LDO buyback loops back to the vault. + const cowReturn = graph.edges.find( + (e) => e.kind === 'returns' && e.source === '0xcow', + ); + expect(cowReturn?.target).toBe('source'); + expect(cowReturn?.token).toBe('LDO'); + }); +}); + +describe('mergeLiveOverlay', () => { + const indexed = toIndexedDynamics({ steps: indexedSteps }); + + it('returns indexed untouched when there is no live overlay', () => { + expect(mergeLiveOverlay(indexed, null)).toBe(indexed); + }); + + it('keeps indexed amounts but takes live state/readings/balances', () => { + const live: IFlowDynamics = { + balances: [{ token: 'stETH', amount: 250 }], + steps: [ + { + address: '0xcow', + index: 2, + state: 'blocked', + blocked: true, + badge: 'gate closed', + skipReason: 'gate closed', + inputReadings: [ + { status: 'closed', detail: 'below floor' }, + ], + }, + ], + }; + const merged = mergeLiveOverlay(indexed, live); + + const cow = merged.steps.find((s) => s.address === '0xcow'); + // Live readiness wins… + expect(cow?.state).toBe('blocked'); + expect(cow?.blocked).toBe(true); + expect(cow?.inputReadings).toEqual([ + { status: 'closed', detail: 'below floor' }, + ]); + // …but the settled amount is preserved. + expect(cow?.outs).toEqual([ + { + token: 'LDO', + amount: 100, + fidelity: 'real', + perEpoch: undefined, + }, + ]); + expect(merged.balances).toEqual([{ token: 'stETH', amount: 250 }]); + expect(merged.runId).toBeNull(); + }); +}); diff --git a/src/modules/flow/canvas/toIndexedDynamics.ts b/src/modules/flow/canvas/toIndexedDynamics.ts new file mode 100644 index 0000000000..5dc5e37a21 --- /dev/null +++ b/src/modules/flow/canvas/toIndexedDynamics.ts @@ -0,0 +1,205 @@ +/** + * Indexed-dynamics producer — projects the indexer's settled provenance graph + * ({@link IFlowIndexedStep}, normalised from `FlowStep` / `FlowEdge` / + * `SwapFill`) onto the generic {@link IFlowDynamics} overlay that + * {@link buildFlowGraph} consumes. + * + * This is the authoritative replacement for the heuristic run reconstruction: + * edge amounts are the real, on-chain-settled values the indexer tagged with a + * provenance (wrap output, LP minted, CoW fill), so the canvas PROJECTS them + * rather than guessing "largest approve = swap sell" or "first wrap = leg + * input". Nothing here is LMM-specific or token-hard-coded — every value comes + * from a {@link IFlowIndexedEdge}. + * + * Two producers: + * - {@link toIndexedDynamics} — a run (replay) or an overview snapshot. + * - {@link mergeLiveOverlay} — layers the RPC live readings (budget / gate / + * epoch sub-node values + vault balances + current would-fire state) on top + * of indexed amounts, for the default "live" view. + */ + +import type { + FlowRuntimeState, + IFlowDynamics, + IFlowEdgeFlow, + IFlowIndexedEdge, + IFlowIndexedStep, + IFlowStepDynamics, +} from './flowGraphTypes'; + +/** Indexed status → canvas runtime state. Executed legs read as `done` + * (historical), gated skips as `blocked`, paused as `skipped`. */ +const stateForStatus = (step: IFlowIndexedStep): FlowRuntimeState => { + switch (step.status) { + case 'executed': + return 'done'; + case 'skippedGated': + return 'blocked'; + case 'skippedPaused': + return 'skipped'; + case 'failed': + return 'failed'; + default: + // no-op / opaque — nothing moved this run. + return 'idle'; + } +}; + +const edgeToFlow = (edge: IFlowIndexedEdge): IFlowEdgeFlow => ({ + token: edge.token, + amount: edge.amount, + fidelity: edge.fidelity, + perEpoch: edge.perEpoch, +}); + +/** + * Every token this step produces — proceeds looped back to the operational DAO + * vault and any output that lands at a different address (a genuine external + * recipient, e.g. UniV2 LP minted to the Lido Agent). All are emitted (flagged + * `external` for the latter) so the canvas draws a real arrow per output. + * + * Classification is by the edge's actual `to`, not just the indexer's role: + * the indexer marks LP-to-Agent as `vaultIn`, but if `to` isn't the operational + * DAO it is shown going to that recipient, matching the live simulation. + */ +const outFlowsForStep = ( + step: IFlowIndexedStep, + daoAddress: string | undefined, +): IFlowEdgeFlow[] => + step.edges + .filter((e) => e.role === 'vaultIn' || e.role === 'external') + .map((edge) => { + const external = + edge.role === 'external' || + (daoAddress != null && + edge.to != null && + edge.to.toLowerCase() !== daoAddress.toLowerCase()); + return { + token: edge.token, + amount: edge.amount, + fidelity: edge.fidelity, + perEpoch: edge.perEpoch, + external: external ? true : undefined, + to: external ? edge.to : undefined, + }; + }); + +/** Tokens this step pulls from the vault (`vaultOut`), deduped by token. */ +const inFlowsForStep = (step: IFlowIndexedStep): IFlowEdgeFlow[] => { + const ins: IFlowEdgeFlow[] = []; + for (const edge of step.edges) { + if (edge.role !== 'vaultOut') { + continue; + } + if (ins.some((i) => i.token === edge.token)) { + continue; + } + ins.push(edgeToFlow(edge)); + } + return ins; +}; + +const compactReason = (reason: string | undefined): string | undefined => { + if (!reason) { + return undefined; + } + return reason.length > 32 ? `${reason.slice(0, 29)}…` : reason; +}; + +const badgeForStep = ( + step: IFlowIndexedStep, + state: FlowRuntimeState, +): string | undefined => { + if (state === 'blocked' || step.status === 'noOp') { + return compactReason(step.reason) ?? 'no-op'; + } + if (state === 'skipped') { + return 'paused'; + } + if (state === 'failed') { + return compactReason(step.reason) ?? 'failed'; + } + return undefined; +}; + +export interface IToIndexedDynamicsParams { + steps: readonly IFlowIndexedStep[]; + /** Non-null marks a replayed historical run; null = live overview. */ + runId?: string | null; + /** The operational DAO vault address. Outputs landing elsewhere are shown as + * external recipients (e.g. LP to the Lido Agent), not looped to the vault. */ + daoAddress?: string; +} + +/** + * Project a set of indexed steps onto {@link IFlowDynamics}. The set is either + * one run's legs (replay) or the latest-per-strategy composite (overview). + */ +export const toIndexedDynamics = ( + params: IToIndexedDynamicsParams, +): IFlowDynamics => { + const { steps, runId = null, daoAddress } = params; + const dynSteps: IFlowStepDynamics[] = steps.map((step) => { + const state = stateForStatus(step); + return { + address: step.address, + index: step.index, + state, + badge: badgeForStep(step, state), + blocked: state === 'blocked', + outs: outFlowsForStep(step, daoAddress), + ins: inFlowsForStep(step), + skipReason: step.reason, + }; + }); + return { runId, steps: dynSteps }; +}; + +/** + * Layer a live RPC overlay onto indexed dynamics for the default view: indexed + * edge amounts (real, settled provenance) are kept, while the live snapshot + * supplies the things the indexer can't know in real time — per-input readings + * (budget / gate / epoch), vault balances, and the current would-fire / blocked + * state of each step. Steps present only in `live` (e.g. a leg that has never + * run but the simulator can still reason about) are appended. + */ +export const mergeLiveOverlay = ( + indexed: IFlowDynamics, + live: IFlowDynamics | null, +): IFlowDynamics => { + if (!live) { + return indexed; + } + const liveByAddress = new Map(); + for (const s of live.steps) { + liveByAddress.set(s.address.toLowerCase(), s); + } + + const merged: IFlowStepDynamics[] = indexed.steps.map((base) => { + const overlay = liveByAddress.get(base.address.toLowerCase()); + liveByAddress.delete(base.address.toLowerCase()); + if (!overlay) { + return base; + } + return { + ...base, + // Live wins for readiness signals; indexed wins for amounts. + state: overlay.state, + badge: overlay.badge ?? base.badge, + blocked: overlay.blocked ?? base.blocked, + inputReadings: overlay.inputReadings ?? base.inputReadings, + skipReason: overlay.skipReason ?? base.skipReason, + }; + }); + + // Legs the indexer hasn't seen yet but the live simulator describes. + for (const leftover of liveByAddress.values()) { + merged.push(leftover); + } + + return { + runId: null, + steps: merged, + balances: live.balances ?? indexed.balances, + }; +}; diff --git a/src/modules/flow/components/flowActivityFeed/flowActivityFeed.tsx b/src/modules/flow/components/flowActivityFeed/flowActivityFeed.tsx new file mode 100644 index 0000000000..f628ffa608 --- /dev/null +++ b/src/modules/flow/components/flowActivityFeed/flowActivityFeed.tsx @@ -0,0 +1,386 @@ +'use client'; + +import classNames from 'classnames'; +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { useFlowNow } from '../../providers/flowDataProvider'; +import { getAllPolicies } from '../../providers/flowSelectors'; +import type { + FlowEventKind, + FlowPolicyStrategy, + FlowTokenSymbol, + IFlowDaoData, + IFlowPolicy, +} from '../../types'; +import { + formatFlowAmount, + formatRelative, + formatShortDate, +} from '../../utils/flowFormatters'; +import { FlowStrategyChip, FlowTokenChip } from '../flowPrimitives'; + +export type FlowActivityVariant = 'preview' | 'full'; + +export interface IFlowActivityFeedProps { + data: IFlowDaoData; + network: string; + addressOrEns: string; + variant?: FlowActivityVariant; + limit?: number; + policyId?: string; + className?: string; +} + +type ActivityRowType = 'dispatch' | FlowEventKind; + +interface IActivityRow { + id: string; + timestamp: number; + at: string; + type: ActivityRowType; + typeLabel: string; + policyId: string; + policyName: string; + token?: FlowTokenSymbol; + tokenIn?: FlowTokenSymbol; + amountIn?: number; + tokenOut?: FlowTokenSymbol; + amountOut?: number; + strategy?: FlowPolicyStrategy; + amount?: number; + recipientLabel?: string; + txHash?: string; + description?: string; + proposalId?: string; + proposalSlug?: string; +} + +const eventTypeLabel: Record = { + policyInstalled: 'Installed', + policyUninstalled: 'Uninstalled', + paused: 'Paused', + resumed: 'Resumed', + settingsUpdated: 'Settings', + proposalApplied: 'Proposal', + recipientsUpdated: 'Recipients', + dispatchFailed: 'Failed dispatch', +}; + +const shortTx = (hash: string): string => + hash.length > 14 ? `${hash.slice(0, 10)}…${hash.slice(-4)}` : hash; + +// LMM_DEMO_HACK: synthetic-policy-installed (shared family). Orchestrator +// legs (Wrap, UniV2 LP, CowSwap) move value to contracts, not to a +// `recipient` row — `recipientsCount` is legitimately 0 there, but the +// generic "No recipients yet" copy is wrong. Describe the leg instead so +// the Activity feed reads as informative for the LMM demo and as a +// strict no-op for production policies. +const describeLeg = ( + legIndex: number | undefined, + legKind: IFlowPolicy['dispatches'][number]['legKind'], +): string | undefined => { + if (legKind == null) { + return undefined; + } + const idx = legIndex != null ? `#${legIndex} · ` : ''; + switch (legKind) { + case 'WRAP': + return `${idx}stETH wrapped into wstETH (held by DAO)`; + case 'UNIV2_LIQUIDITY': + return `${idx}Liquidity added to UniV2 pair`; + case 'GATED_COWSWAP': + case 'COWSWAP': + return `${idx}CowSwap order pre-signed`; + case 'EPOCH_TRANSFER': + return `${idx}Epoch transfer`; + case 'BURN': + return `${idx}Burned`; + case 'TRANSFER': + return undefined; // fall through to recipient summary + default: + return `${idx}${legKind}`; + } +}; + +const buildRecipientLabel = ( + policy: IFlowPolicy, + dispatch: IFlowPolicy['dispatches'][number], +): string | undefined => { + if (dispatch.recipientsCount === 0) { + return describeLeg(dispatch.legIndex, dispatch.legKind); + } + if (dispatch.recipientsCount === 1) { + const top = dispatch.topRecipients[0]; + if (top) { + return top.name; + } + const fallback = policy.recipients[0]; + return fallback?.name ?? 'single recipient'; + } + return `${dispatch.recipientsCount} recipients`; +}; + +const buildRows = (data: IFlowDaoData, policyId?: string): IActivityRow[] => { + const rows: IActivityRow[] = []; + // Walk leaves + orchestrator mirrors so the LMM dispatcher's lifecycle + // events (installed, settings updated) and per-leg dispatches show up + // in the feed. See `getAllPolicies` for the merge rules. + for (const policy of getAllPolicies(data)) { + if (policyId != null && policy.id !== policyId) { + continue; + } + for (const dispatch of policy.dispatches) { + rows.push({ + id: `d-${dispatch.id}`, + timestamp: new Date(dispatch.at).getTime(), + at: dispatch.at, + type: 'dispatch', + typeLabel: policy.verb, + policyId: policy.id, + policyName: policy.name, + token: dispatch.token, + tokenIn: dispatch.tokenIn, + amountIn: dispatch.amountIn, + tokenOut: dispatch.tokenOut, + amountOut: dispatch.amountOut, + strategy: policy.strategy, + amount: dispatch.amount, + recipientLabel: buildRecipientLabel(policy, dispatch), + txHash: dispatch.txHash, + proposalId: dispatch.proposalId, + proposalSlug: dispatch.proposalSlug, + }); + } + for (const event of policy.events) { + rows.push({ + id: `e-${event.id}`, + timestamp: new Date(event.at).getTime(), + at: event.at, + type: event.kind, + typeLabel: eventTypeLabel[event.kind] ?? 'Event', + policyId: policy.id, + policyName: policy.name, + strategy: policy.strategy, + description: event.description, + proposalId: event.proposalId, + proposalSlug: event.proposalSlug, + txHash: event.txHash, + }); + } + } + return rows.sort((a, b) => b.timestamp - a.timestamp); +}; + +// Shared grid template: time · type · policy · detail · context · tx +const ROW_GRID = + 'grid items-center gap-x-4 gap-y-1 md:grid-cols-[80px_100px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_120px]'; + +export const FlowActivityFeed: React.FC = (props) => { + const { + data, + network, + addressOrEns, + variant = 'full', + limit, + policyId, + className, + } = props; + + const now = useFlowNow(); + const allRows = useMemo(() => buildRows(data, policyId), [data, policyId]); + + const [policyFilter, setPolicyFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); + + const rows = useMemo(() => { + if (variant === 'preview') { + return allRows.slice(0, limit ?? 6); + } + return allRows.filter((row) => { + if (policyFilter !== 'all' && row.policyId !== policyFilter) { + return false; + } + if (typeFilter !== 'all') { + if (typeFilter === 'dispatch' && row.type !== 'dispatch') { + return false; + } + if (typeFilter === 'event' && row.type === 'dispatch') { + return false; + } + } + return true; + }); + }, [allRows, variant, limit, policyFilter, typeFilter]); + + return ( +
+
+

+ {variant === 'preview' ? 'Recent activity' : 'Activity'} +

+ {variant === 'preview' ? ( + + View all → + + ) : ( +
+ + +
+ )} +
+ +
    + {rows.map((row) => ( +
  • + + {formatRelative(row.at, now)} + + + {row.typeLabel} + + + {row.policyName} + + + + + + {formatShortDate(row.at)} + +
  • + ))} + {rows.length === 0 && ( +
  • + No activity matches the current filters. +
  • + )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Cells +// --------------------------------------------------------------------------- + +const DetailCell: React.FC<{ row: IActivityRow }> = ({ row }) => { + if (row.type === 'dispatch' && row.amount != null && row.token != null) { + // Swap dispatch → "IN amount + chip → OUT chip (+ amount if we have it)". + // Uniswap OUT amount is usually unknown (pool-side settlement), so we + // degrade gracefully to just "IN → OUT chip". + if (row.amountIn != null && row.tokenIn != null && row.tokenOut) { + return ( + + + {formatFlowAmount(row.amountIn, row.tokenIn)} + + + + → + + {row.amountOut != null ? ( + + {formatFlowAmount(row.amountOut, row.tokenOut)} + + ) : null} + + + ); + } + return ( + + + {formatFlowAmount(row.amount, row.token)} + + + + ); + } + return ( + + {row.strategy != null && ( + + )} + + ); +}; + +const ContextCell: React.FC<{ + row: IActivityRow; + network: string; + addressOrEns: string; +}> = ({ row, network, addressOrEns }) => { + if (row.type === 'dispatch') { + return ( + + → {row.recipientLabel ?? '—'} + + ); + } + const hasProposal = + typeof row.proposalSlug === 'string' && row.proposalSlug.length > 0; + const suffix = hasProposal ? ` · Proposal ${row.proposalId}` : ''; + + if (hasProposal) { + return ( + + {row.description ?? '—'} + {suffix} + + ); + } + return ( + + {row.description ?? '—'} + + ); +}; + +const TxCell: React.FC<{ row: IActivityRow }> = ({ row }) => { + if (!row.txHash) { + return ; + } + return ( + + {shortTx(row.txHash)} + + ); +}; diff --git a/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx b/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx new file mode 100644 index 0000000000..ea396172ba --- /dev/null +++ b/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx @@ -0,0 +1,216 @@ +'use client'; + +import classNames from 'classnames'; +import { useFlowDataContext } from '../../providers/flowDataProvider'; +import { + derivePolicyStatus, + getAllPolicies, + summariseLmmLegs, +} from '../../providers/flowSelectors'; +import type { FlowTokenSymbol, IFlowDaoData } from '../../types'; +import { formatFlowAmount } from '../../utils/flowFormatters'; + +export interface IFlowKpiRowProps { + data: IFlowDaoData; +} + +interface IKpiItem { + label: string; + value: string; + hint?: string; + deltaLabel?: string; + deltaTone?: 'up' | 'down' | 'flat'; + tone?: 'default' | 'critical' | 'primary'; +} + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +const toneClasses: Record, string> = { + default: 'border-neutral-100', + critical: 'border-critical-200 bg-critical-50', + // Neutral primary tint — matches the gov-ui-kit highlight without the + // saturated blue background of the previous `primary-50` variant. + primary: 'border-primary-200', +}; + +const deltaClasses: Record<'up' | 'down' | 'flat', string> = { + up: 'text-neutral-700', + down: 'text-critical-700', + flat: 'text-neutral-500', +}; + +export const FlowKpiRow: React.FC = (props) => { + const { data } = props; + const { now, liveSnapshot } = useFlowDataContext(); + + // Iterate both leaves AND orchestrator mirrors so KPI numbers no longer + // ignore the LMM dispatcher (and any future multi-router orchestrator). + const allPolicies = getAllPolicies(data); + + // LMM_DEMO_HACK: pending-from-live. Merge the live RPC snapshot's + // per-leg budgets into the dispatcher policy's pending view so + // `Ready to dispatch` reports the real on-chain queue. Otherwise + // the orchestrator looks empty until the indexer surfaces an + // execution. See providers/flowSelectors.ts → summariseLmmLegs. + const lmmAggregate = summariseLmmLegs(liveSnapshot?.snapshot ?? null); + const lmmDispatcher = liveSnapshot?.dispatcherAddress; + + const readyPolicies = allPolicies.filter((p) => { + // Treat the LMM orchestrator as "ready" whenever any leg has + // non-zero pending budget — even before the first dispatch. + const isLmmOrchestrator = + lmmDispatcher != null && + p.address.toLowerCase() === lmmDispatcher.toLowerCase(); + const pending = isLmmOrchestrator ? lmmAggregate.dominant : p.pending; + return derivePolicyStatus(p, now, pending).status === 'ready'; + }); + const readyCount = readyPolicies.length; + + // Ready-summary subtext: prefer the per-leg breakdown from the live + // snapshot ("100 stETH + 36.4 LDO") over the per-policy pending, + // since for orchestrators every leg consumes a different token. + const formatLeg = (amount: number, token: FlowTokenSymbol): string => + `${formatFlowAmount(amount, token)} ${token}`; + const lmmSubtitleParts = lmmAggregate.legs + .filter((l) => l.amount > 0) + .slice(0, 2) + .map((l) => formatLeg(l.amount, l.token)); + + const pendingByToken = readyPolicies.reduce< + Partial> + >((acc, policy) => { + if (policy.pending == null) { + return acc; + } + acc[policy.pending.token] = + (acc[policy.pending.token] ?? 0) + policy.pending.amount; + return acc; + }, {}); + const flatPendingSubtitleParts = Object.entries(pendingByToken).map( + ([token, amount]) => + formatLeg(amount as number, token as FlowTokenSymbol), + ); + const subtitleParts = + lmmSubtitleParts.length > 0 + ? lmmSubtitleParts + : flatPendingSubtitleParts; + const pendingSummary = + subtitleParts.length > 0 + ? subtitleParts.join(' + ') + : readyCount === 0 + ? 'nothing queued' + : 'no queued amounts'; + + const dispatches7d = allPolicies.reduce( + (sum, policy) => + sum + + policy.dispatches.filter( + (d) => + d.status !== 'failed' && + now - new Date(d.at).getTime() <= WEEK_MS, + ).length, + 0, + ); + const dispatchesPrev7d = allPolicies.reduce( + (sum, policy) => + sum + + policy.dispatches.filter((d) => { + if (d.status === 'failed') { + return false; + } + const ts = new Date(d.at).getTime(); + const delta = now - ts; + return delta > WEEK_MS && delta <= 2 * WEEK_MS; + }).length, + 0, + ); + const dispatchesDelta = dispatches7d - dispatchesPrev7d; + const dispatchesDeltaTone: IKpiItem['deltaTone'] = + dispatchesDelta > 0 ? 'up' : dispatchesDelta < 0 ? 'down' : 'flat'; + const dispatchesDeltaLabel = + dispatchesDelta === 0 + ? 'same as prev 7d' + : `${dispatchesDelta > 0 ? '+' : ''}${dispatchesDelta} vs prev 7d`; + + const failed7d = allPolicies.reduce( + (sum, policy) => + sum + + policy.dispatches.filter( + (d) => + d.status === 'failed' && + now - new Date(d.at).getTime() <= WEEK_MS, + ).length, + 0, + ); + const failedEver = allPolicies.reduce( + (sum, policy) => + sum + policy.dispatches.filter((d) => d.status === 'failed').length, + 0, + ); + + const items: IKpiItem[] = [ + { + label: 'Ready to dispatch', + value: `${readyCount}`, + hint: pendingSummary, + tone: readyCount > 0 ? 'primary' : 'default', + }, + { + label: 'Dispatches · 7d', + value: `${dispatches7d}`, + deltaLabel: dispatchesDeltaLabel, + deltaTone: dispatchesDeltaTone, + }, + failed7d > 0 + ? { + label: 'Failed · 7d', + value: `${failed7d}`, + hint: `${failedEver} total failures`, + tone: 'critical', + } + : { + label: 'Failed · 7d', + value: '0', + hint: 'All operational', + tone: 'default', + }, + ]; + + return ( +
+ {items.map((item) => ( +
+ + {item.label} + + + {item.value} + + {item.deltaLabel != null && ( + + {item.deltaTone === 'up' && '▲ '} + {item.deltaTone === 'down' && '▼ '} + {item.deltaLabel} + + )} + {item.hint != null && ( + + {item.hint} + + )} +
+ ))} +
+ ); +}; diff --git a/src/modules/flow/components/flowLayout/flowLayout.tsx b/src/modules/flow/components/flowLayout/flowLayout.tsx new file mode 100644 index 0000000000..dd198155f4 --- /dev/null +++ b/src/modules/flow/components/flowLayout/flowLayout.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; +import { FlowDataProvider } from '../../providers/flowDataProvider'; +import { FlowLayoutShell } from './flowLayoutShell'; + +export interface IFlowLayoutProps { + daoId: string; + network: string; + addressOrEns: string; + children?: ReactNode; +} + +export const FlowLayout: React.FC = (props) => { + const { daoId, network, addressOrEns, children } = props; + + return ( + + + {children} + + + ); +}; diff --git a/src/modules/flow/components/flowLayout/flowLayoutShell.tsx b/src/modules/flow/components/flowLayout/flowLayoutShell.tsx new file mode 100644 index 0000000000..5508bec823 --- /dev/null +++ b/src/modules/flow/components/flowLayout/flowLayoutShell.tsx @@ -0,0 +1,56 @@ +'use client'; + +import classNames from 'classnames'; +import { usePathname } from 'next/navigation'; +import type { ReactNode } from 'react'; +import { FlowSubNav } from '../flowSubNav/flowSubNav'; +import { FlowToastStack } from '../flowToastStack/flowToastStack'; +import { FlowTopbar } from '../flowTopbar/flowTopbar'; + +export interface IFlowLayoutShellProps { + daoId: string; + network: string; + addressOrEns: string; + children?: ReactNode; +} + +/** + * Chrome shell for every Flow sub-page. The Workbench needs a full-bleed, + * viewport-bounded canvas (its own internal scroll regions + auto-fit stage), + * so on that route we drop the narrow centered `
` and bound the height; + * every other Flow page keeps the standard 1280px column. + */ +export const FlowLayoutShell: React.FC = (props) => { + const { daoId, network, addressOrEns, children } = props; + const pathname = usePathname() ?? ''; + const base = `/dao/${network}/${addressOrEns}/flow`; + const isWorkbench = pathname.startsWith(`${base}/workbench`); + + return ( +
+ + + {isWorkbench ? ( +
{children}
+ ) : ( +
+ {children} +
+ )} + +
+ ); +}; diff --git a/src/modules/flow/components/flowLede/flowLede.tsx b/src/modules/flow/components/flowLede/flowLede.tsx new file mode 100644 index 0000000000..466abb5ed3 --- /dev/null +++ b/src/modules/flow/components/flowLede/flowLede.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useFlowDataContext } from '../../providers/flowDataProvider'; +import { + derivePolicyStatus, + getAllPolicies, + summariseLmmLegs, +} from '../../providers/flowSelectors'; +import type { FlowTokenSymbol, IFlowDaoData } from '../../types'; +import { formatFlowAmount, formatRelative } from '../../utils/flowFormatters'; + +export interface IFlowLedeProps { + data: IFlowDaoData; +} + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +export const FlowLede: React.FC = (props) => { + const { data } = props; + const { dao } = data; + const { now, liveSnapshot } = useFlowDataContext(); + + // Iterate every policy (leaves + orchestrator mirrors) so the lede + // talks about the orchestrator the same way it talks about leaves. + const allPolicies = getAllPolicies(data); + const lmmAggregate = summariseLmmLegs(liveSnapshot?.snapshot ?? null); + const lmmDispatcher = liveSnapshot?.dispatcherAddress; + + const readyPolicies = allPolicies.filter((p) => { + const isLmmOrchestrator = + lmmDispatcher != null && + p.address.toLowerCase() === lmmDispatcher.toLowerCase(); + const pending = isLmmOrchestrator ? lmmAggregate.dominant : p.pending; + return derivePolicyStatus(p, now, pending).status === 'ready'; + }); + + const pendingByToken = readyPolicies.reduce< + Partial> + >((acc, policy) => { + if (policy.pending == null) { + return acc; + } + acc[policy.pending.token] = + (acc[policy.pending.token] ?? 0) + policy.pending.amount; + return acc; + }, {}); + + const lmmSubtitleParts = lmmAggregate.legs + .filter((l) => l.amount > 0) + .slice(0, 2) + .map((l) => `${formatFlowAmount(l.amount, l.token)} ${l.token}`); + const flatPendingParts = Object.entries(pendingByToken).map( + ([token, amount]) => + `${formatFlowAmount(amount as number, token as FlowTokenSymbol)} ${token}`, + ); + const pendingSummary = + lmmSubtitleParts.length > 0 + ? lmmSubtitleParts.join(' + ') + : flatPendingParts.join(' + '); + + const cooldownPolicies = allPolicies + .filter( + (p) => + p.cooldown != null && + derivePolicyStatus(p, now).status !== 'ready', + ) + .map((p) => p.cooldown!) + .sort( + (a, b) => + new Date(a.readyAt).getTime() - new Date(b.readyAt).getTime(), + ); + const nextReady = cooldownPolicies[0]; + + const failed7d = allPolicies.reduce( + (sum, policy) => + sum + + policy.dispatches.filter( + (d) => + d.status === 'failed' && + now - new Date(d.at).getTime() <= WEEK_MS, + ).length, + 0, + ); + + const narrativeParts: string[] = []; + if (readyPolicies.length > 0) { + narrativeParts.push( + `${pendingSummary || `${readyPolicies.length}`} ready to dispatch across ${readyPolicies.length} polic${readyPolicies.length === 1 ? 'y' : 'ies'}`, + ); + } + if (nextReady != null) { + narrativeParts.push( + `next autonomous dispatch ${formatRelative(nextReady.readyAt, now)}`, + ); + } + if (failed7d > 0) { + narrativeParts.push(`${failed7d} failed last week`); + } else { + narrativeParts.push('all operational'); + } + + return ( +
+

+ {dao.name} +

+

+ {narrativeParts.join(' · ')}. +

+
+ ); +}; diff --git a/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx b/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx new file mode 100644 index 0000000000..052143f1c9 --- /dev/null +++ b/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx @@ -0,0 +1,538 @@ +'use client'; + +import { Button } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import Link from 'next/link'; +import { useFlowDataContext } from '../../providers/flowDataProvider'; +import type { + IFlowEmbeddedStrategy, + IFlowOrchestrator, + IFlowOrchestratorLeg, + IFlowOrchestratorRun, +} from '../../types'; +import { + formatFlowAmount, + formatRelative, + formatShortDate, +} from '../../utils/flowFormatters'; +import { + FlowStatusDot, + FlowStrategyChip, + FlowTokenChip, + FlowWaitingForIndexerBadge, +} from '../flowPrimitives'; + +export interface IFlowMultiDispatchCardProps { + orchestrator: IFlowOrchestrator; + network: string; + addressOrEns: string; + className?: string; +} + +const cardBorderByStatus: Record = { + ready: 'border-primary-200', + // Live cards used `border-success-200` (mint). Neutral keeps the card + // grid quiet; status colour lives only in the `FlowStatusDot`. + live: 'border-neutral-200', + cooldown: 'border-neutral-200', + awaiting: 'border-neutral-200', + paused: 'border-warning-300', + never: 'border-neutral-100', +}; + +/** + * Wide card rendering a Multi-dispatch orchestrator: + * - Top: name / chain diagram (horizontal, each chip links to the child policy detail). + * - Bottom: up to 4 most-recent runs, grouped by txHash, with a vertical leg list. + * + * Orchestrator dispatch is proxied to `dispatchPolicy(orchestrator.id)` — the existing + * mock/live pipeline treats the id like a regular plugin, which is enough for the POC. + */ +export const FlowMultiDispatchCard: React.FC = ( + props, +) => { + const { orchestrator, network, addressOrEns, className } = props; + const editHref = `/dao/${network}/${addressOrEns}/settings/automations/${orchestrator.address}`; + // Orchestrators resolve through `data.orchestratorPolicies` on the policy + // detail route, which renders the rich `LmmPolicyTopology` graph + chart + + // history. See `IFlowDaoData.orchestratorPolicies` for the lookup contract. + const detailHref = `/dao/${network}/${addressOrEns}/flow/policies/${encodeURIComponent( + orchestrator.id, + )}`; + + const { dispatchPolicy, getPendingDispatch } = useFlowDataContext(); + const pendingDispatch = getPendingDispatch(orchestrator.id); + const isDispatching = pendingDispatch != null; + const isArchived = orchestrator.status === 'paused'; + const hasEmbedded = (orchestrator.embeddedStrategies?.length ?? 0) > 0; + const hasChain = + hasEmbedded || orchestrator.chain.some((child) => child != null); + + const handleDispatch = () => { + dispatchPolicy(orchestrator.id); + }; + + // Orchestrators typically have many legs × many runs, which used to bloat the card well + // past a policy card's height. We cap the in-card history at the 2 most recent runs; + // older runs are collapsed behind a "+N older runs" counter (non-clickable for now). + const MAX_VISIBLE_RUNS = 2; + const visibleRuns = orchestrator.runs.slice(0, MAX_VISIBLE_RUNS); + const hiddenRunsCount = Math.max( + 0, + orchestrator.runs.length - visibleRuns.length, + ); + + return ( +
+
+ +
+ +

+ {orchestrator.name} +

+
+

+ {orchestrator.description} +

+ +
+ + {/* Quiet text link instead of an outlined pill — the chip + * next to it (when present) already carries the visual + * weight, and a second rounded shape next to it read as + * noise on the card header. */} + + Edit ↗ + +
+
+ + {hasEmbedded ? ( + + ) : ( + + )} + +
+
+

+ Recent runs +

+ + {orchestrator.totalRuns === 0 + ? 'Never dispatched' + : `${orchestrator.totalRuns} total`} + +
+ {orchestrator.runs.length === 0 ? ( +
+ No dispatches yet — the chain above is configured but + hasn't been triggered. +
+ ) : ( +
    + {visibleRuns.map((run) => ( + + ))} +
+ )} + {hiddenRunsCount > 0 && ( + + +{hiddenRunsCount} older run + {hiddenRunsCount === 1 ? '' : 's'} + + )} +
+ + {/* `mt-auto` pins the pending badge + CTA to the card bottom so cards line + up in the grid regardless of how many runs each shows above. */} +
+ {pendingDispatch != null && ( + + )} + + {isArchived ? ( + + {orchestrator.uninstalledAt + ? `Archived · uninstalled ${formatRelative(orchestrator.uninstalledAt)}` + : 'Archived · uninstalled'} + + ) : ( + + )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Chain diagram +// --------------------------------------------------------------------------- + +interface IFlowChainDiagramProps { + orchestrator: IFlowOrchestrator; + network: string; + addressOrEns: string; +} + +const FlowChainDiagram: React.FC = ({ + orchestrator, + network, + addressOrEns, +}) => { + if (orchestrator.chain.length === 0) { + return ( +
+ No sub-routers configured. +
+ ); + } + + return ( +
+ {orchestrator.chain.map((child, index) => ( + + ))} +
+ ); +}; + +interface IChainChipProps { + child: IFlowOrchestrator['chain'][number]; + network: string; + addressOrEns: string; + showArrow: boolean; +} + +const ChainChip: React.FC = ({ + child, + network, + addressOrEns, + showArrow, +}) => { + if (child == null) { + return ( + <> + + Unknown sub-router + + {showArrow && } + + ); + } + const href = `/dao/${network}/${addressOrEns}/flow/policies/${child.id}`; + return ( + <> + + {child.strategy} + · + {child.swapPair ? ( + + {child.swapPair.in} + + {child.swapPair.out} + + ) : ( + {child.token} + )} + + {showArrow && } + + ); +}; + +const ChainArrow: React.FC = () => ( + + → + +); + +// --------------------------------------------------------------------------- +// Embedded-strategy chain (Dispatcher-owned strategies) +// --------------------------------------------------------------------------- + +interface IFlowEmbeddedChainDiagramProps { + strategies: IFlowEmbeddedStrategy[]; + /** Route base for the per-strategy deep-link. Each chip routes to + * `/dao/{network}/{addressOrEns}/flow/policies/{orchestratorId}?node={strategyAddress}` + * so the dispatcher detail page can auto-select the matching topology + * node + open NodeDetails on it. */ + orchestratorId: string; + network: string; + addressOrEns: string; +} + +/** + * Render the dispatcher's embedded strategy chain. Each chip exposes the + * strategy kind + key per-leg chips: budget kind (Stream/Full), + * gate kind (PriceFloor) and epoch length where present. Falls through + * gracefully when an LMM dispatcher hasn't finished initializing all of + * its sub-components yet. + */ +const FlowEmbeddedChainDiagram: React.FC = ({ + strategies, + orchestratorId, + network, + addressOrEns, +}) => ( +
+ {strategies.map((s, index) => ( + + ))} +
+); + +// LMM_DEMO_HACK: STRATEGY_KIND_TONE used to ship per-kind pastel tints +// (`primary-50` wrap / `violet-50` UniV2 / `cyan-50` CowSwap). We +// neutralized those so the embedded chain reads as a single chain of +// equal chips; the strategy *label* carries enough semantic weight +// without competing pastel backgrounds. +const STRATEGY_KIND_TONE: Record = { + wrap: 'border-neutral-200 bg-neutral-0', + univ2Liquidity: 'border-neutral-200 bg-neutral-0', + gatedCowSwap: 'border-neutral-200 bg-neutral-0', + cowSwap: 'border-neutral-200 bg-neutral-0', + transfer: 'border-neutral-200 bg-neutral-0', + epochTransfer: 'border-neutral-200 bg-neutral-0', + burn: 'border-critical-200 bg-critical-50', + unknown: 'border-neutral-200 bg-neutral-0', +}; + +const EmbeddedStrategyChip: React.FC<{ + strategy: IFlowEmbeddedStrategy; + showArrow: boolean; + orchestratorId: string; + network: string; + addressOrEns: string; +}> = ({ strategy, showArrow, orchestratorId, network, addressOrEns }) => { + const seconds = strategy.epochProvider?.epochLength; + const epochLabel = formatEpochLength(seconds); + // Deep-link into the dispatcher detail page with the matching topology + // node pre-selected. The dispatcher page reads `?node=` via + // `useSearchParams()` and threads the address down to TopologyView, + // which opens NodeDetails on the matching node. Strategy address is + // the only stable identifier we can derive from the chip — topology + // node ids are path-based and not user-meaningful. + const href = `/dao/${network}/${addressOrEns}/flow/policies/${encodeURIComponent( + orchestratorId, + )}?node=${strategy.address}`; + return ( + <> + +
+ + {strategy.label} + + {strategy.paused && ( + + Paused + + )} +
+
+ {strategy.budget && ( + + {strategy.budget.kind === 'streamUntil' + ? 'Stream' + : strategy.budget.kind === 'full' + ? 'Full' + : strategy.budget.kind === 'required' + ? 'Required' + : 'Budget'} + + )} + {strategy.gate && ( + + {strategy.gate.kind === 'priceFloor' + ? 'PriceFloor' + : 'Gate'} + + )} + {epochLabel && ( + + Epoch {epochLabel} + + )} +
+ + {showArrow && } + + ); +}; + +const formatEpochLength = (seconds?: string): string | undefined => { + if (seconds == null) { + return undefined; + } + const n = Number(seconds); + if (!Number.isFinite(n) || n <= 0) { + return undefined; + } + if (n >= 86_400) { + const d = n / 86_400; + return `${Number.isInteger(d) ? d : d.toFixed(1)}d`; + } + if (n >= 3600) { + return `${Math.round(n / 3600)}h`; + } + return `${Math.round(n / 60)}m`; +}; + +// --------------------------------------------------------------------------- +// Run row (vertical list of legs) +// --------------------------------------------------------------------------- + +interface IRunRowProps { + run: IFlowOrchestratorRun; + network: string; + addressOrEns: string; +} + +const RunRow: React.FC = ({ run, network, addressOrEns }) => ( +
  • +
    + + {formatShortDate(run.at)} + + + {formatRelative(run.at)} · {run.txHash.slice(0, 10)}… + +
    +
      + {run.legs.map((leg) => ( + + ))} +
    +
  • +); + +interface IRunLegProps { + leg: IFlowOrchestratorLeg; + network: string; + addressOrEns: string; +} + +const RunLeg: React.FC = ({ leg, network, addressOrEns }) => { + const href = `/dao/${network}/${addressOrEns}/flow/policies/${leg.policyId}`; + const isFailed = leg.status === 'failed'; + const isSkipped = leg.status === 'skipped'; + return ( +
  • + + + {leg.policyName} + + + {leg.amountIn != null && leg.tokenIn ? ( + <> + + {formatFlowAmount(leg.amountIn, leg.tokenIn)} + + + + + ) : null} + {formatFlowAmount(leg.amountOut, leg.tokenOut)} + + · + + {leg.recipientsCount === 0 + ? 'No recipients' + : `${leg.recipientsCount} recipient${leg.recipientsCount === 1 ? '' : 's'}`} + + +
  • + ); +}; diff --git a/src/modules/flow/components/flowOrchestrators/flowOrchestratorsSection.tsx b/src/modules/flow/components/flowOrchestrators/flowOrchestratorsSection.tsx new file mode 100644 index 0000000000..bbadb758ab --- /dev/null +++ b/src/modules/flow/components/flowOrchestrators/flowOrchestratorsSection.tsx @@ -0,0 +1,54 @@ +'use client'; + +import type { IFlowOrchestrator } from '../../types'; +import { FlowMultiDispatchCard } from './flowMultiDispatchCard'; + +export interface IFlowOrchestratorsSectionProps { + orchestrators: readonly IFlowOrchestrator[]; + network: string; + addressOrEns: string; +} + +/** + * Lifted "Orchestrators" section shown above the Policies section on the Flow overview. + * Only rendered when the DAO has at least one `multiDispatch`/`multiRouter`/`multiClaimer` + * policy — otherwise the Policies section takes the whole vertical space. + */ +export const FlowOrchestratorsSection: React.FC< + IFlowOrchestratorsSectionProps +> = (props) => { + const { orchestrators, network, addressOrEns } = props; + + if (orchestrators.length === 0) { + return null; + } + + return ( +
    +
    +
    +

    + Orchestrators +

    +

    + Multi-dispatch policies chaining other routers — a + single run fans out into the legs below. +

    +
    + + {orchestrators.length} + +
    +
    + {orchestrators.map((orchestrator) => ( + + ))} +
    +
    + ); +}; diff --git a/src/modules/flow/components/flowOrchestrators/index.ts b/src/modules/flow/components/flowOrchestrators/index.ts new file mode 100644 index 0000000000..ca18fdf946 --- /dev/null +++ b/src/modules/flow/components/flowOrchestrators/index.ts @@ -0,0 +1,2 @@ +export { FlowMultiDispatchCard } from './flowMultiDispatchCard'; +export { FlowOrchestratorsSection } from './flowOrchestratorsSection'; diff --git a/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx b/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx new file mode 100644 index 0000000000..3f65e7c833 --- /dev/null +++ b/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx @@ -0,0 +1,148 @@ +'use client'; + +import classNames from 'classnames'; +import { useMemo, useState } from 'react'; +import { useAddAutomationAction } from '../../hooks/useAddAutomationAction'; +import { useFlowDataContext } from '../../providers/flowDataProvider'; +import type { IFlowGroupedPolicies, IFlowPolicy } from '../../types'; +import { FlowPolicyCard } from '../flowPolicyCard/flowPolicyCard'; + +export interface IFlowPoliciesSectionProps { + groupedPolicies: IFlowGroupedPolicies; + network: string; + addressOrEns: string; +} + +type PillId = 'active' | 'neverRun' | 'archived'; + +interface IPill { + id: PillId; + label: string; + count: number; + policies: readonly IFlowPolicy[]; + variant: 'default' | 'compact'; +} + +/** + * Policies overview with a pill filter. The pills only render when their bucket has at + * least one policy — e.g. a DAO with no archived automations never sees the "Archived" + * pill. Default selection = first non-empty bucket (Active → Not yet dispatched → Archived). + */ +export const FlowPoliciesSection: React.FC = ( + props, +) => { + const { groupedPolicies, network, addressOrEns } = props; + + const pills = useMemo( + () => + [ + { + id: 'active' as const, + label: 'Active', + count: groupedPolicies.active.length, + policies: groupedPolicies.active, + variant: 'default' as const, + }, + { + id: 'neverRun' as const, + label: 'Not yet dispatched', + count: groupedPolicies.neverRun.length, + policies: groupedPolicies.neverRun, + variant: 'compact' as const, + }, + { + id: 'archived' as const, + label: 'Archived', + count: groupedPolicies.archived.length, + policies: groupedPolicies.archived, + variant: 'compact' as const, + }, + ].filter((pill) => pill.count > 0), + [groupedPolicies], + ); + + // `selectedRaw` is what the user explicitly clicked. `active` is the + // pill we actually render — derived from `pills`, with a fallback to the + // first available bucket when the user's previous selection is no longer + // in the list (e.g. dispatch moved a policy out of "Not yet dispatched"). + // Keeping this purely derived avoids the setState-in-useEffect loop that + // fired when `pills` was reference-unstable from upstream refetches. + const [selectedRaw, setSelected] = useState('active'); + const active = pills.find((p) => p.id === selectedRaw) ?? pills[0]; + const selected = active?.id ?? selectedRaw; + + // Mirror the Settings > Automations "Add automation" action — opens the + // wizard details dialog, then routes into `/create/{plugin}/policy`. + // The DAO id lives on the FlowDataProvider so we can grab it from context + // without threading a prop through every section. + const { daoId } = useFlowDataContext(); + const { startAddAutomation, isReady: isAddAutomationReady } = + useAddAutomationAction({ daoId }); + + return ( +
    +
    +
    +

    + Policies +

    + {pills.length > 1 && ( +
    + {pills.map((pill) => ( + + ))} +
    + )} +
    + +
    + + {active && active.policies.length > 0 ? ( +
    + {active.policies.map((policy) => ( + + ))} +
    + ) : ( +
    + No policies in this bucket yet. +
    + )} +
    + ); +}; diff --git a/src/modules/flow/components/flowPoliciesSection/index.ts b/src/modules/flow/components/flowPoliciesSection/index.ts new file mode 100644 index 0000000000..be0d45f998 --- /dev/null +++ b/src/modules/flow/components/flowPoliciesSection/index.ts @@ -0,0 +1 @@ +export { FlowPoliciesSection } from './flowPoliciesSection'; diff --git a/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx b/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx new file mode 100644 index 0000000000..e7cbf06000 --- /dev/null +++ b/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx @@ -0,0 +1,444 @@ +'use client'; + +import { Button } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useDialogContext } from '@/shared/components/dialogProvider'; +import { FlowDialogId } from '../../constants/flowDialogId'; +import type { IConfirmDispatchDialogParams } from '../../dialogs/confirmDispatchDialog'; +import { useFlowDataContext } from '../../providers/flowDataProvider'; +import type { FlowPolicyStatus, IFlowPolicy } from '../../types'; +import { + formatFlowAmount, + formatFlowAmountWithToken, + formatRelative, + isDispatchableStrategy, +} from '../../utils/flowFormatters'; +import { + FlowAddressLabel, + FlowBlockieAvatar, + FlowStatusDot, + FlowStrategyChip, + FlowTokenChip, + FlowWaitingForIndexerBadge, +} from '../flowPrimitives'; +import { FlowSparkline } from '../flowSparkline/flowSparkline'; + +export interface IFlowPolicyCardProps { + policy: IFlowPolicy; + network: string; + addressOrEns: string; + /** + * Density variant: + * - `default` (Active pill): full card with sparkline + recipients strip. + * - `compact` (Not-yet-dispatched / Archived pills): hides sparkline and the + * totals row since there's no meaningful history to show. + */ + variant?: 'default' | 'compact'; + className?: string; +} + +const cardStatusBorder: Record = { + ready: 'border-primary-200', + // Neutralise live + cooldown card borders so card grids on /flow read + // monochromatic — the dot in the header carries the status hue. + live: 'border-neutral-200', + cooldown: 'border-neutral-200', + awaiting: 'border-neutral-200', + paused: 'border-warning-300', + never: 'border-neutral-100', +}; + +export const FlowPolicyCard: React.FC = (props) => { + const { + policy, + network, + addressOrEns, + variant = 'default', + className, + } = props; + const href = `/dao/${network}/${addressOrEns}/flow/policies/${policy.id}`; + const editHref = `/dao/${network}/${addressOrEns}/settings/automations/${policy.address}`; + const isCompact = variant === 'compact'; + + const router = useRouter(); + const { open } = useDialogContext(); + const { dispatchPolicy, getPendingDispatch } = useFlowDataContext(); + const pendingDispatch = getPendingDispatch(policy.id); + + const handleNavigate = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (target.closest('button, a')) { + return; + } + router.push(href); + }; + + const handleDispatchClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const params: IConfirmDispatchDialogParams = { + policy, + onConfirm: () => dispatchPolicy(policy.id), + }; + open(FlowDialogId.CONFIRM_DISPATCH, { params }); + }; + + return ( + // biome-ignore lint/a11y/noNoninteractiveElementInteractions: card is a composite interactive surface, keyboard reachable via the title link and action buttons +
    { + if (e.key === 'Enter' || e.key === ' ') { + const target = e.target as HTMLElement; + if (!target.closest('button, a')) { + e.preventDefault(); + router.push(href); + } + } + }} + > +
    +
    +
    + + + {policy.name} + + {policy.failedLastDispatch != null && ( + + Failed + + )} +
    +

    + {policy.description} +

    +
    +
    + {policy.swapPair ? ( + + + + → + + + + ) : ( + + )} + + e.stopPropagation()} + rel="noopener" + target="_blank" + > + Edit ↗ + +
    +
    + + {!isCompact && ( + + )} + +
    +
    + + {formatFlowAmountWithToken( + policy.totalDistributed, + policy.token, + )} + + + Total since install + + {policy.lastDispatch != null && ( + + Last:{' '} + + {formatFlowAmount( + policy.lastDispatch.amount, + policy.lastDispatch.token, + )}{' '} + {policy.lastDispatch.token} + {' '} + · {formatRelative(policy.lastDispatch.at)} + + )} +
    + {policy.pending != null && ( +
    + + {isDispatchableStrategy(policy.strategy) + ? 'Pending' + : 'Claimable'} + + + {formatFlowAmount( + policy.pending.amount, + policy.pending.token, + )}{' '} + {policy.pending.token} + +
    + )} +
    + +
    + {policy.recipients.slice(0, 3).map((recipient) => ( +
    + + + {recipient.pct != null && ( + + {recipient.pct}% + + )} +
    + ))} + {policy.recipientsMore > 0 && ( + + +{policy.recipientsMore} more · {policy.recipientGroup} + + )} +
    + + {/* `mt-auto` on this group pins the badge + CTA to the bottom of the card + so cards of different content heights share a common baseline in the grid. */} +
    + {pendingDispatch != null && ( + + )} + + +
    +
    + ); +}; + +interface IPolicyCardFooterProps { + policy: IFlowPolicy; + href: string; + onDispatch: (e: React.MouseEvent) => void; + /** + * `true` while a dispatch tx has been broadcast but the indexer hasn't surfaced + * it yet — the dispatch button is disabled to prevent double-submits and a + * helper caption is shown instead of the usual CTA label. + */ + isDispatching?: boolean; +} + +const PolicyCardFooter: React.FC = (props) => { + const { policy, onDispatch, isDispatching = false } = props; + + // LMM_DEMO_HACK: when the anvil fork has been warped past the cooldown + // via the cheats menu we want the Dispatch button to unlock immediately. + // The mapper's cooldown.readyAt is computed off the on-chain + // lastDispatchAt + epoch length, so comparing against chain time + // (anvil HEAD timestamp) rather than wall-clock time is what matches + // the on-chain enforcement. Undefined in production / when no warp. + const { chainNowMs } = useFlowDataContext(); + const nowMs = chainNowMs ?? Date.now(); + + // Archived policies never dispatch again — show an uninstall chip instead. + if (policy.status === 'paused') { + const uninstalledLabel = policy.uninstalledAt + ? `Archived · uninstalled ${formatRelative(policy.uninstalledAt, nowMs)}` + : 'Archived · uninstalled'; + return {uninstalledLabel}; + } + + // Pull-based strategies (Claimer) can't be pushed from the UI. + if (!isDispatchableStrategy(policy.strategy)) { + return ( + + {policy.statusLabel || 'Open for claims'} + + ); + } + + // Every other strategy gets a Dispatch button. When a cooldown is active the button is + // disabled and the ring inside `CooldownFooter` tells the operator when to come back. + const cooldownActive = + policy.status === 'cooldown' && + policy.cooldown != null && + new Date(policy.cooldown.readyAt).getTime() > nowMs; + + const pending = policy.pending; + const dispatchLabel = pending + ? `Dispatch now · ${formatFlowAmount(pending.amount, pending.token)} ${pending.token}` + : 'Dispatch now'; + + const buttonLabel = isDispatching ? 'Dispatching…' : dispatchLabel; + + return ( +
    + + {cooldownActive && policy.cooldown != null && ( + + )} + {policy.status === 'never' && !cooldownActive && ( + + Not yet dispatched — the first run will seed the chart. + + )} + {policy.failedLastDispatch != null && ( + + Last attempt failed · {policy.failedLastDispatch.reason} + + )} +
    + ); +}; + +const CooldownFooter: React.FC<{ + cooldown: NonNullable; +}> = ({ cooldown }) => { + const readyAtMs = new Date(cooldown.readyAt).getTime(); + + // Progress depends on `Date.now()`, which differs between server render + // and client hydration. Start with `null` to render a neutral state on the + // server and fill it in after mount so SSR HTML matches the first client + // render, then tick every second to animate the cooldown ring. + const [now, setNow] = useState(null); + + useEffect(() => { + setNow(Date.now()); + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, []); + + const progress = + now == null + ? 0 + : Math.min( + Math.max( + 1 - Math.max(readyAtMs - now, 0) / cooldown.totalMs, + 0, + ), + 1, + ); + const radius = 12; + const circumference = 2 * Math.PI * radius; + + return ( +
    + + Cooldown progress + + + +
    + + Ready {formatRelative(cooldown.readyAt)} + + + {Math.round(progress * 100)}% of cooldown elapsed + +
    +
    + ); +}; + +const FooterChip: React.FC<{ + tone: 'warning' | 'neutral'; + children: React.ReactNode; +}> = ({ tone, children }) => { + const toneClass = { + warning: 'bg-warning-100 text-warning-800', + neutral: 'bg-neutral-100 text-neutral-700', + }[tone]; + return ( + + {children} + + ); +}; diff --git a/src/modules/flow/components/flowPolicyChart/flowEventStyles.ts b/src/modules/flow/components/flowPolicyChart/flowEventStyles.ts new file mode 100644 index 0000000000..147ee6e341 --- /dev/null +++ b/src/modules/flow/components/flowPolicyChart/flowEventStyles.ts @@ -0,0 +1,85 @@ +import type { FlowEventKind } from '../../types'; + +export const FLOW_FAILED_COLOR = '#dc2626'; +// Skipped dispatches (gate closed, etc) — a softer warm grey so they read +// as "neither success nor failure" but stay distinguishable from on-time +// dispatches. Used by the timeline marker and the cumulative tooltip. +export const FLOW_SKIPPED_COLOR = '#a16207'; +// Swap dispatch markers — slightly heavier accent than `dispatchOk` so the +// chart legend can call out swap legs (relevant for the LMM demo where +// gated CowSwap dispatches are the main flow). +export const FLOW_SWAP_COLOR = '#0891b2'; + +/** + * Human-readable labels for lifecycle events. Shared between the big policy + * chart and the overview sparkline so their tooltips stay consistent. + */ +export const FLOW_EVENT_KIND_LABEL: Record = { + policyInstalled: 'Installed', + policyUninstalled: 'Uninstalled', + paused: 'Paused', + resumed: 'Resumed', + settingsUpdated: 'Settings', + proposalApplied: 'Proposal', + recipientsUpdated: 'Recipients', + dispatchFailed: 'Failed', +}; + +/** + * Marker colors for lifecycle events. Picked to be easily distinguishable from + * each other AND from token colors (USDC #2775ca, MERC #003bf5, WETH #1f2933). + * + * Loosely grouped by semantics: + * - positive / active: install (emerald), resumed (lime) + * - negative / terminal: uninstalled (dark red), failed (bright red) + * - attention: paused (amber) + * - governance / config: settings (indigo), proposal (violet), recipients (pink) + */ +export const FLOW_EVENT_KIND_TONE: Record = { + // Neutral installed marker (was emerald #10b981) — keeps the chart + // monochromatic so the user's eye lands on the dispatched markers + // instead of the lifecycle column. Aligns with `flowStatusDot`'s + // neutralisation and the Aragon palette in general. + policyInstalled: '#475569', + policyUninstalled: '#991b1b', + paused: '#f59e0b', + resumed: '#3b82f6', + settingsUpdated: '#6366f1', + proposalApplied: '#a855f7', + recipientsUpdated: '#ec4899', + dispatchFailed: FLOW_FAILED_COLOR, +}; + +export type FlowMarkerKind = + | 'dispatchOk' + | 'dispatchFailed' + | 'dispatchSkipped' + | 'dispatchSwap' + | FlowEventKind; + +export interface IFlowTimelineMarker { + id: string; + timestamp: number; + kind: FlowMarkerKind; + label: string; + detail: string; +} + +export const getFlowMarkerColor = ( + kind: FlowMarkerKind, + tokenColor: string, +): string => { + if (kind === 'dispatchOk') { + return tokenColor; + } + if (kind === 'dispatchFailed') { + return FLOW_FAILED_COLOR; + } + if (kind === 'dispatchSkipped') { + return FLOW_SKIPPED_COLOR; + } + if (kind === 'dispatchSwap') { + return FLOW_SWAP_COLOR; + } + return FLOW_EVENT_KIND_TONE[kind]; +}; diff --git a/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx b/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx new file mode 100644 index 0000000000..9e250101ec --- /dev/null +++ b/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx @@ -0,0 +1,597 @@ +'use client'; + +import classNames from 'classnames'; +import { useMemo } from 'react'; +import { + Area, + CartesianGrid, + ComposedChart, + ReferenceLine, + ResponsiveContainer, + Scatter, + ScatterChart, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { FlowEventKind, IFlowPolicy } from '../../types'; +import { + formatFlowAmount, + formatShortDate, + getTokenColor, +} from '../../utils/flowFormatters'; +import { + FLOW_EVENT_KIND_LABEL, + FLOW_EVENT_KIND_TONE, + FLOW_FAILED_COLOR, + FLOW_SKIPPED_COLOR, + FLOW_SWAP_COLOR, + getFlowMarkerColor, + type IFlowTimelineMarker, +} from './flowEventStyles'; + +export interface IFlowPolicyChartProps { + policy: IFlowPolicy; + /** + * DAO context so the chart tooltip can hyperlink the event `proposalId` + * to `/dao/{network}/{addressOrEns}/proposals/{proposalSlug}`. Optional — + * without them the tooltip still renders, just non-clickable. + */ + network?: string; + addressOrEns?: string; + className?: string; +} + +interface IChartPoint { + timestamp: number; + historic: number | null; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const FlowPolicyChart: React.FC = (props) => { + const { policy, className } = props; + const color = getTokenColor(policy.token); + const id = `flowChartGradient-${policy.id}`; + + const { data, markers, cumulativeTotal, minTs, maxTs } = useMemo(() => { + const historicSorted = [...policy.dispatches].sort( + (a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(), + ); + + const eventTimestamps = policy.events.map((e) => + new Date(e.at).getTime(), + ); + const dispatchTimestamps = historicSorted.map((d) => + new Date(d.at).getTime(), + ); + const createdAtMs = new Date(policy.createdAt).getTime(); + + const earliestAnchor = Math.min( + createdAtMs, + ...eventTimestamps, + ...(dispatchTimestamps.length > 0 + ? [dispatchTimestamps[0]] + : [createdAtMs]), + ); + const latestAnchor = Math.max( + createdAtMs, + ...eventTimestamps, + ...dispatchTimestamps, + ); + const span = Math.max(latestAnchor - earliestAnchor, DAY_MS); + const pad = Math.max(2 * DAY_MS, span * 0.03); + const startTs = earliestAnchor - pad; + const endTs = latestAnchor + pad; + + const points: IChartPoint[] = []; + let cumulative = 0; + + points.push({ timestamp: startTs, historic: 0 }); + + for (const dispatch of historicSorted) { + const ts = new Date(dispatch.at).getTime(); + // Only successful dispatches contribute to the cumulative curve — + // both `failed` and `skipped` outcomes show up as timeline markers + // but leave the running total untouched. Additionally for + // orchestrator (multi-dispatch) policies the cumulative MUST be + // single-token: every leg lands here with potentially different + // tokens (stETH for Wrap, wstETH+LDO for UniV2, wstETH for + // CowSwap), so we only fold legs whose token matches the + // policy headline. Single-token policies effectively no-op + // this check because `dispatch.token === policy.token` for + // every row. + const matchesHeadlineToken = dispatch.token === policy.token; + if ( + matchesHeadlineToken && + dispatch.status !== 'failed' && + dispatch.status !== 'skipped' + ) { + cumulative += dispatch.amount; + } + points.push({ timestamp: ts, historic: cumulative }); + } + + const historicEnd = cumulative; + points.push({ timestamp: endTs, historic: historicEnd }); + + const dispatchMarkers: IFlowTimelineMarker[] = historicSorted.map( + (dispatch) => { + const isSwap = + dispatch.tokenIn != null && dispatch.amountIn != null; + // Sentinel from `envioFlowMapper.ts` for CowSwap legs that + // ran but haven't produced a SwapOrder yet — render as a + // generic "Dispatch" marker with a leg-aware tooltip so we + // never echo "0 " back to the user. + const isSentinel = dispatch.token === ''; + let kind: IFlowTimelineMarker['kind']; + let label: string; + if (dispatch.status === 'failed') { + kind = 'dispatchFailed'; + label = 'Failed dispatch'; + } else if (dispatch.status === 'skipped') { + kind = 'dispatchSkipped'; + label = 'Skipped dispatch'; + } else if (isSentinel) { + kind = 'dispatchOk'; + label = + dispatch.legKind === 'GATED_COWSWAP' || + dispatch.legKind === 'COWSWAP' + ? 'CowSwap pre-sign' + : 'Dispatch'; + } else if (isSwap) { + kind = 'dispatchSwap'; + label = 'Swap dispatch'; + } else { + kind = 'dispatchOk'; + label = 'Dispatch'; + } + // Compose a tooltip line that pairs IN→OUT for swap dispatches + // and falls back to the single-leg form otherwise. + let detail: string; + if (isSentinel) { + detail = `${label} · ${formatShortDate(dispatch.at)}`; + } else if ( + isSwap && + dispatch.amountIn != null && + dispatch.tokenIn + ) { + const inLeg = `${formatFlowAmount(dispatch.amountIn, dispatch.tokenIn)} ${dispatch.tokenIn}`; + const outLeg = `${formatFlowAmount(dispatch.amount, dispatch.token)} ${dispatch.token}`; + detail = `${inLeg} → ${outLeg} · ${formatShortDate(dispatch.at)}`; + } else { + detail = `${formatFlowAmount(dispatch.amount, dispatch.token)} ${dispatch.token} · ${formatShortDate(dispatch.at)}`; + } + if (dispatch.status === 'failed' && dispatch.failureReason) { + detail += ` · ${dispatch.failureReason}`; + } + if (dispatch.status === 'skipped' && dispatch.skippedReason) { + detail += ` · ${dispatch.skippedReason}`; + } + return { + id: `dispatch-${dispatch.id}`, + timestamp: new Date(dispatch.at).getTime(), + kind, + label, + detail, + }; + }, + ); + + const eventMarkers: IFlowTimelineMarker[] = policy.events + .filter((event) => new Date(event.at).getTime() >= startTs) + .map((event) => ({ + id: `event-${event.id}`, + timestamp: new Date(event.at).getTime(), + kind: event.kind, + label: FLOW_EVENT_KIND_LABEL[event.kind], + detail: `${event.title} · ${formatShortDate(event.at)}${event.description ? ` · ${event.description}` : ''}`, + })); + + const allMarkers = [...dispatchMarkers, ...eventMarkers].sort( + (a, b) => a.timestamp - b.timestamp, + ); + + return { + data: points, + markers: allMarkers, + cumulativeTotal: historicEnd, + minTs: startTs, + maxTs: endTs, + }; + }, [policy]); + + const presentEventKinds = useMemo( + () => new Set(policy.events.map((e) => e.kind)), + [policy.events], + ); + const hasFailedDispatch = policy.dispatches.some( + (d) => d.status === 'failed', + ); + const hasSkippedDispatch = policy.dispatches.some( + (d) => d.status === 'skipped', + ); + const hasSwapDispatch = policy.dispatches.some( + (d) => + d.status !== 'failed' && + d.status !== 'skipped' && + d.tokenIn != null, + ); + const hasOkDispatch = policy.dispatches.some( + (d) => d.status !== 'failed' && d.status !== 'skipped', + ); + + return ( +
    +
    +
    + + Cumulative {policy.verb} + + + {formatFlowAmount(cumulativeTotal, policy.token)}{' '} + + {policy.token} + + +
    +
    + + + + + {( + [ + 'policyInstalled', + 'policyUninstalled', + 'settingsUpdated', + 'recipientsUpdated', + 'paused', + 'resumed', + 'proposalApplied', + ] as FlowEventKind[] + ).map((kind) => ( + + ))} +
    +
    + +
    + {/* `initialDimension` overrides recharts' default `{-1,-1}` — + * without it the first paint logs a "width(-1)/height(-1)" + * dev warning before the ResizeObserver fires. We know the + * container is 280px tall, so seed that height; the real + * width is filled in on the next frame. */} + + + + + + + + + + + + formatFlowAmount(v, policy.token) + } + tickLine={false} + width={56} + /> + { + const payload = tooltipProps.payload as + | ReadonlyArray<{ payload: IChartPoint }> + | undefined; + return ( + + ); + }} + cursor={{ + stroke: '#9ca3af', + strokeDasharray: '3 3', + }} + isAnimationActive={false} + /> + + + +
    + + + +

    + Line shows cumulative {policy.verb} over time — hover for the + exact value. Dots on the timeline below mark individual + dispatches (cyan for swaps, amber for skipped, red for failed) + and lifecycle events. +

    +
    + ); +}; + +const LegendChip: React.FC<{ + color: string; + label: string; + outline?: boolean; + dim?: boolean; +}> = ({ color, label, outline, dim }) => ( + + + {label} + +); + +interface ITimelineBandProps { + markers: IFlowTimelineMarker[]; + minTs: number; + maxTs: number; + tokenColor: string; +} + +interface ITimelineScatterPoint { + x: number; + y: number; + marker: IFlowTimelineMarker; + fill: string; +} + +const TimelineBand: React.FC = ({ + markers, + minTs, + maxTs, + tokenColor, +}) => { + const scatterData: ITimelineScatterPoint[] = useMemo( + () => + markers.map((marker) => ({ + x: marker.timestamp, + y: 0.5, + marker, + fill: getFlowMarkerColor(marker.kind, tokenColor), + })), + [markers, tokenColor], + ); + + return ( +
    + + + + formatShortDate(new Date(ts).toISOString()) + } + tickLine={{ stroke: '#e5e7eb' }} + type="number" + /> + + + { + const payload = tooltipProps.payload as + | ReadonlyArray<{ + payload: ITimelineScatterPoint; + }> + | undefined; + return ( + + ); + }} + cursor={false} + isAnimationActive={false} + /> + ( + + )} + /> + + +
    + ); +}; + +interface ITimelineDotShapeProps { + cx?: number; + cy?: number; + payload?: ITimelineScatterPoint; +} + +const TimelineDotShape: React.FC = ({ + cx = 0, + cy = 0, + payload, +}) => { + if (payload == null) { + return ; + } + return ( + + ); +}; + +interface ITimelineTooltipProps { + active?: boolean; + payload?: Array<{ payload: ITimelineScatterPoint }>; +} + +const TimelineTooltip: React.FC = ({ + active, + payload, +}) => { + if (!active || payload == null || payload.length === 0) { + return null; + } + const { marker, fill } = payload[0].payload; + return ( +
    +
    + + + {marker.label} + +
    + + {marker.detail} + +
    + ); +}; + +interface ICumulativeTooltipProps { + active?: boolean; + payload?: Array<{ payload: IChartPoint }>; + policy: IFlowPolicy; +} + +const CumulativeTooltip: React.FC = (props) => { + const { active, payload, policy } = props; + if (!active || payload == null || payload.length === 0) { + return null; + } + const point = payload[0].payload; + return ( +
    + + {formatShortDate(new Date(point.timestamp).toISOString())} + + + {formatFlowAmount(point.historic ?? 0, policy.token)}{' '} + + {policy.token} + + + + Cumulative {policy.verb} + +
    + ); +}; diff --git a/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx b/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx new file mode 100644 index 0000000000..cb2b83552e --- /dev/null +++ b/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx @@ -0,0 +1,349 @@ +'use client'; + +import classNames from 'classnames'; +import { useMemo, useState } from 'react'; +import { useFlowNow } from '../../providers/flowDataProvider'; +import type { FlowEventKind, IFlowPolicy } from '../../types'; +import { + formatFlowAmount, + formatRelative, + formatShortDate, +} from '../../utils/flowFormatters'; +import { FlowTokenChip } from '../flowPrimitives'; + +export interface IFlowPolicyHistoryProps { + policy: IFlowPolicy; + className?: string; +} + +type HistoryFilter = 'all' | 'dispatches' | 'events' | 'failed'; + +interface IHistoryRow { + id: string; + kind: 'dispatch' | FlowEventKind; + at: string; + timestamp: number; + title: string; + description?: string; + amount?: number; + tokenSymbol?: IFlowPolicy['token']; + recipientsCount?: number; + txHash?: string; + proposalId?: string; + failed?: boolean; +} + +const eventTone: Record = { + policyInstalled: 'bg-neutral-100 text-neutral-700', + policyUninstalled: 'bg-critical-100 text-critical-800', + paused: 'bg-warning-100 text-warning-800', + // Neutralise "Resumed" — was mint-green; matches the rest of the + // monochromatic /flow surface. + resumed: 'bg-neutral-100 text-neutral-700', + settingsUpdated: 'bg-neutral-100 text-neutral-700', + proposalApplied: 'bg-primary-100 text-primary-800', + recipientsUpdated: 'bg-info-100 text-info-800', + dispatchFailed: 'bg-critical-100 text-critical-800', +}; + +const eventTypeLabel: Record = { + policyInstalled: 'Installed', + policyUninstalled: 'Uninstalled', + paused: 'Paused', + resumed: 'Resumed', + settingsUpdated: 'Settings', + proposalApplied: 'Proposal', + recipientsUpdated: 'Recipients', + dispatchFailed: 'Failed', +}; + +const filterLabels: Record = { + all: 'All', + dispatches: 'Dispatches', + events: 'Events', + failed: 'Failed', +}; + +// Human-readable label per orchestrator-leg strategy kind. Single-dispatch +// routers and non-orchestrator strategies leave `legKind` undefined and +// fall back to the policy.verb-driven title below. +const LEG_KIND_LABEL: Record< + NonNullable, + string +> = { + WRAP: 'Wrap', + UNIV2_LIQUIDITY: 'UniV2 LP', + GATED_COWSWAP: 'CowSwap', + COWSWAP: 'CowSwap', + TRANSFER: 'Transfer', + EPOCH_TRANSFER: 'Epoch transfer', + BURN: 'Burn', + UNKNOWN: 'Strategy', +}; + +const legDescription = ( + kind: IFlowPolicy['dispatches'][number]['legKind'], +): string => { + switch (kind) { + case 'WRAP': + return 'stETH wrapped into wstETH (held by DAO)'; + case 'UNIV2_LIQUIDITY': + return 'Liquidity added to UniV2 pair (LP tokens to DAO)'; + case 'GATED_COWSWAP': + case 'COWSWAP': + return 'CowSwap order pre-signed (off-chain fill)'; + case 'BURN': + return 'Tokens burned'; + case 'TRANSFER': + case 'EPOCH_TRANSFER': + return 'Transferred to recipients'; + default: + return 'No recipients'; + } +}; + +const buildRows = (policy: IFlowPolicy): IHistoryRow[] => { + const rows: IHistoryRow[] = []; + for (const dispatch of policy.dispatches) { + const isFailed = dispatch.status === 'failed'; + const isSkipped = dispatch.status === 'skipped'; + // Multi-dispatch leg context: prefix the row title with the leg + // index + kind ("#0 · Wrap leg") so a single orchestrator dispatch + // that lands as 3 rows is visually disambiguated. Falls back to + // the policy.verb form for single-dispatch routers where legKind + // is absent. + const hasLegContext = dispatch.legKind != null; + const legLabel = dispatch.legKind + ? LEG_KIND_LABEL[dispatch.legKind] + : undefined; + const titleBase = isFailed + ? 'Dispatch failed' + : isSkipped + ? 'Dispatch skipped' + : hasLegContext && legLabel + ? `#${dispatch.legIndex ?? '?'} · ${legLabel}` + : `${policy.verb.charAt(0).toUpperCase()}${policy.verb.slice(1)}`; + rows.push({ + id: `d-${dispatch.id}`, + kind: 'dispatch', + at: dispatch.at, + timestamp: new Date(dispatch.at).getTime(), + title: titleBase, + description: isFailed + ? (dispatch.failureReason ?? 'Dispatch reverted.') + : isSkipped + ? (dispatch.skippedReason ?? 'Strategy was skipped.') + : dispatch.recipientsCount === 0 + ? hasLegContext + ? // Orchestrator legs: tokens stay inside the pipeline + // (wrap consumed → wstETH back to DAO; UniV2 → LP + // tokens back to DAO; CowSwap → off-chain fill). + // Describe what the leg did instead of "no + // recipients", which would read as a defect. + legDescription(dispatch.legKind) + : 'No recipients' + : `${dispatch.recipientsCount} recipient${dispatch.recipientsCount === 1 ? '' : 's'}`, + amount: dispatch.amount, + tokenSymbol: dispatch.token, + recipientsCount: dispatch.recipientsCount, + txHash: dispatch.txHash, + failed: isFailed, + }); + } + for (const event of policy.events) { + rows.push({ + id: `e-${event.id}`, + kind: event.kind, + at: event.at, + timestamp: new Date(event.at).getTime(), + title: event.title, + description: event.description, + txHash: event.txHash, + proposalId: event.proposalId, + failed: event.kind === 'dispatchFailed', + }); + } + return rows.sort((a, b) => b.timestamp - a.timestamp); +}; + +export const FlowPolicyHistory: React.FC = (props) => { + const { policy, className } = props; + const [filter, setFilter] = useState('all'); + const now = useFlowNow(); + + const rows = useMemo(() => buildRows(policy), [policy]); + const filtered = useMemo(() => { + if (filter === 'all') { + return rows; + } + if (filter === 'dispatches') { + return rows.filter((r) => r.kind === 'dispatch' && !r.failed); + } + if (filter === 'events') { + return rows.filter((r) => r.kind !== 'dispatch'); + } + return rows.filter((r) => r.failed === true); + }, [rows, filter]); + + const counts = useMemo( + () => ({ + all: rows.length, + dispatches: rows.filter((r) => r.kind === 'dispatch' && !r.failed) + .length, + events: rows.filter((r) => r.kind !== 'dispatch').length, + failed: rows.filter((r) => r.failed === true).length, + }), + [rows], + ); + + return ( +
    +
    +

    + History +

    +
    + {(Object.keys(filterLabels) as HistoryFilter[]).map( + (key) => ( + + ), + )} +
    +
    + +
      + {filtered.map((row) => ( +
    • + + {formatRelative(row.at, now)} + + +
      + + {row.title} + + {row.description != null && ( + + {row.description} + + )} +
      + {row.amount != null && + row.tokenSymbol != null && + // Sentinel from `envioFlowMapper.ts` — orchestrator + // legs without a meaningful headline (CowSwap leg + // that hasn't surfaced its SwapOrder, etc.) ship + // `token: ''` so we render leg name + description + // without a misleading "0 USDC" amount + chip. + row.tokenSymbol !== '' && ( + + + {formatFlowAmount( + row.amount, + row.tokenSymbol, + )} + + + + )} + {row.proposalId != null && ( + + {row.proposalId} + + )} + {row.txHash != null && ( + + {row.txHash} + + )} + + {formatShortDate(row.at)} + +
    • + ))} + {filtered.length === 0 && ( +
    • + No entries match this filter yet. +
    • + )} +
    +
    + ); +}; + +const TypeTag: React.FC<{ + kind: IHistoryRow['kind']; + failed?: boolean; +}> = ({ kind, failed }) => { + if (kind === 'dispatch') { + return ( + + {failed ? 'Failed' : 'Dispatch'} + + ); + } + return ( + + {eventTypeLabel[kind]} + + ); +}; diff --git a/src/modules/flow/components/flowPolicyStructure/flowPolicyStructure.tsx b/src/modules/flow/components/flowPolicyStructure/flowPolicyStructure.tsx new file mode 100644 index 0000000000..cd6bd21502 --- /dev/null +++ b/src/modules/flow/components/flowPolicyStructure/flowPolicyStructure.tsx @@ -0,0 +1,233 @@ +'use client'; + +import classNames from 'classnames'; +// LMM_DEMO_HACK: the legacy "Structure breakdown" panel below assumes the +// classic multi-router shape (source vault / allowance / model / sub-router +// rows). The LMM Money Machine dispatcher renders its full topology + live +// status via `LmmPolicyTopology` (mounted from `FlowPolicyTree`), so the +// breakdown block degrades into a row of em-dashes and a list of bare +// strategy addresses. Hide it for the LMM dispatcher; keep it for every +// other multi-dispatch / multi-router policy. +import { useIsLmmDispatcherPolicy } from '../../demo/useLmmManifest'; +import type { IFlowPolicy, IFlowPolicySubRouter } from '../../types'; +import { + FlowAddressLabel, + FlowBlockieAvatar, + FlowTokenChip, +} from '../flowPrimitives'; +import { FlowPolicyTree } from './flowPolicyTree'; + +export interface IFlowPolicyStructureProps { + policy: IFlowPolicy; + /** Optional address to pre-select in the rich LMM topology (used by the + * dashboard orchestrator chip deep-link). No-op for non-LMM policies. */ + selectedNodeAddress?: string; + className?: string; +} + +export const FlowPolicyStructure: React.FC = ( + props, +) => { + const { policy, selectedNodeAddress, className } = props; + const isLmmDispatcher = useIsLmmDispatcherPolicy(policy.address); + + if ( + policy.strategy !== 'Multi-dispatch' || + policy.schema.subRouters == null + ) { + return null; + } + + if (isLmmDispatcher) { + return ( +
    + +
    + ); + } + + return ( +
    + + +
    +
    +

    + Structure breakdown +

    +

    + Source → Allowance → Model → Recipients. Nested routers + shown indented. +

    +
    + + + + {policy.schema.allowance.type} + + + } + /> + + +
    + + Sub-routers + +
    + {policy.schema.subRouters.map((router) => ( + + ))} +
    +
    +
    +
    + ); +}; + +interface IFlowStructureStepProps { + header: string; + value: React.ReactNode; + hint?: string; +} + +const FlowStructureStep: React.FC = (props) => { + const { header, value, hint } = props; + return ( +
    +
    + + +
    +
    + + {header} + + + {value} + + {hint != null && ( + + {hint} + + )} +
    +
    + ); +}; + +interface IFlowSubRouterNodeProps { + router: IFlowPolicySubRouter; + depth: number; +} + +const FlowSubRouterNode: React.FC = (props) => { + const { router, depth } = props; + const indent = depth === 0 ? 'ml-0' : 'ml-4 md:ml-8'; + return ( +
    +
    + + {router.title} + + {router.subtitle != null && ( + + {router.subtitle} + + )} +
    + {router.allowance != null && ( +
    + + Allowance + + + {router.allowance.type} · {router.allowance.detail} + +
    + )} + {router.model != null && ( +
    + + Model + + + {router.model.type} · {router.model.detail} + +
    + )} + {router.recipients != null && router.recipients.length > 0 && ( +
    + + Recipients + +
      + {router.recipients.map((recipient) => ( +
    • + + + {recipient.ratio != null && ( + + {recipient.ratio} + + )} +
    • + ))} +
    +
    + )} + {router.subRouters != null && router.subRouters.length > 0 && ( +
    + {router.subRouters.map((child) => ( + + ))} +
    + )} +
    + ); +}; diff --git a/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx b/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx new file mode 100644 index 0000000000..ba8fa18b84 --- /dev/null +++ b/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx @@ -0,0 +1,366 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import type { Address } from 'viem'; +// LMM_DEMO_HACK: route multi-dispatch policies to the vendored React Flow +// topology when the demo flag is set + the manifest matches. Falls through +// to the legacy SVG tree for every other policy. +import { LMM_DEMO_MODE } from '../../demo/lmmDemoConfig'; +import { useLmmManifest } from '../../demo/useLmmManifest'; +import type { + IFlowPolicy, + IFlowPolicySubRouter, + IFlowRecipient, +} from '../../types'; +import { LmmPolicyTopology } from '../lidoMoneyMachine/LmmPolicyTopology'; + +export interface IFlowPolicyTreeProps { + policy: IFlowPolicy; + /** Optional address to pre-select in the LMM topology. Threaded through + * from the policy-detail page's `?node=` search param so a chip click on + * the dashboard orchestrator card lands on the right node. */ + selectedNodeAddress?: string; + className?: string; +} + +type NodeType = 'source' | 'router' | 'leaf'; + +interface ITreeNode { + id: string; + type: NodeType; + title: string; + subtitle?: string; + edgeLabel?: string; + children: ITreeNode[]; + col: number; + row: number; + parentId?: string; +} + +const NODE_WIDTH = 184; +const NODE_HEIGHT = 64; +const COL_GAP = 56; +const ROW_GAP = 16; +const PAD = 16; + +const extractEdgeLabel = (subtitle: string | undefined): string | undefined => { + if (subtitle == null) { + return undefined; + } + const match = subtitle.match(/(\d+(?:\.\d+)?\s*%)/); + return match?.[1]; +}; + +const splitSource = (source: string): { title: string; subtitle?: string } => { + const idx = source.indexOf('·'); + if (idx === -1) { + return { title: source }; + } + return { + title: source.slice(0, idx).trim(), + subtitle: source.slice(idx + 1).trim(), + }; +}; + +const buildRecipientNode = ( + recipient: IFlowRecipient, + parentId: string, + index: number, +): ITreeNode => ({ + id: `${parentId}:leaf-${index}-${recipient.address}`, + type: 'leaf', + // `recipient.name` is already a known label (address book hit) or a truncated + // fallback — SVG tree nodes can't host React components so ENS enrichment has + // to skip this view; subtitle shows the truncated hex for copy/paste. + title: recipient.ens ?? recipient.name, + subtitle: addressUtils.truncateAddress(recipient.address), + edgeLabel: + recipient.ratio ?? + (recipient.pct != null ? `${recipient.pct}%` : undefined), + children: [], + parentId, + col: 0, + row: 0, +}); + +const buildRouterNode = ( + router: IFlowPolicySubRouter, + parentId: string, +): ITreeNode => { + const node: ITreeNode = { + id: router.id, + type: 'router', + title: router.title, + subtitle: router.model?.type ?? router.subtitle, + edgeLabel: extractEdgeLabel(router.subtitle), + children: [], + parentId, + col: 0, + row: 0, + }; + const recipients = router.recipients ?? []; + const nestedRouters = router.subRouters ?? []; + node.children = [ + ...nestedRouters.map((r) => buildRouterNode(r, node.id)), + ...recipients.map((r, i) => buildRecipientNode(r, node.id, i)), + ]; + return node; +}; + +const buildTree = (policy: IFlowPolicy): ITreeNode => { + const { title, subtitle } = splitSource(policy.schema.source); + const root: ITreeNode = { + id: `${policy.id}:source`, + type: 'source', + title: title || policy.name, + subtitle: subtitle ?? `${policy.strategy} policy`, + children: [], + col: 0, + row: 0, + }; + const subRouters = policy.schema.subRouters ?? []; + root.children = subRouters.map((r) => buildRouterNode(r, root.id)); + return root; +}; + +/** + * Recursively assign columns and rows so leaves advance a shared counter and + * each parent sits at the midpoint between its first and last child. Produces + * a deterministic, compact layout without requiring post-pass compaction. + */ +const layoutTree = (root: ITreeNode): { cols: number; rows: number } => { + let leafCursor = 0; + let maxDepth = 0; + + const walk = (node: ITreeNode, depth: number): void => { + node.col = depth; + maxDepth = Math.max(maxDepth, depth); + if (node.children.length === 0) { + node.row = leafCursor; + leafCursor += 1; + return; + } + node.children.forEach((child) => walk(child, depth + 1)); + const childRows = node.children.map((c) => c.row); + node.row = (Math.min(...childRows) + Math.max(...childRows)) / 2; + }; + + walk(root, 0); + return { cols: maxDepth + 1, rows: Math.max(leafCursor, 1) }; +}; + +const flatten = (root: ITreeNode): ITreeNode[] => { + const out: ITreeNode[] = []; + const stack: ITreeNode[] = [root]; + while (stack.length > 0) { + const n = stack.pop()!; + out.push(n); + for (const child of n.children) { + stack.push(child); + } + } + return out; +}; + +const nodeTypeClass: Record = { + source: 'border-primary-300 bg-primary-50', + router: 'border-neutral-200 bg-neutral-0', + leaf: 'border-neutral-100 bg-neutral-50', +}; + +const nodeTypeTagClass: Record = { + source: 'text-primary-700', + router: 'text-neutral-500', + leaf: 'text-neutral-500', +}; + +const nodeTypeLabel: Record = { + source: 'SOURCE', + router: 'ROUTER', + leaf: 'RECIPIENT', +}; + +export const FlowPolicyTree: React.FC = (props) => { + const { policy, selectedNodeAddress, className } = props; + + // LMM_DEMO_HACK: when the manifest is loaded and this is the LMM demo + // multi-dispatch policy, render the rich React Flow topology vendored + // from Jordi's preview-lib instead of the SVG tree. Production DAOs + // (and any non-multi-dispatch demo policy) fall through to the legacy + // tree below. + const { manifest } = useLmmManifest(); + const isLmmDispatcher = + LMM_DEMO_MODE && + manifest != null && + policy.strategy === 'Multi-dispatch' && + policy.address.toLowerCase() === + manifest.lmm.dispatcherPlugin.toLowerCase(); + if (isLmmDispatcher && manifest) { + return ( +
    +
    +

    + Capital flow topology +

    +

    + Live view of the dispatcher → strategy → budget chain as + it currently exists on chain. +

    +
    + +
    + ); + } + + const root = buildTree(policy); + const { cols, rows } = layoutTree(root); + const nodes = flatten(root); + + const width = PAD * 2 + cols * NODE_WIDTH + (cols - 1) * COL_GAP; + const height = PAD * 2 + rows * NODE_HEIGHT + (rows - 1) * ROW_GAP; + + const nodeX = (col: number) => PAD + col * (NODE_WIDTH + COL_GAP); + const nodeY = (row: number) => PAD + row * (NODE_HEIGHT + ROW_GAP); + + const byId = new Map(nodes.map((n) => [n.id, n])); + + return ( +
    +
    +

    + Policy tree +

    +

    + Source → routers → recipients. Hover a node to trace its + path. +

    +
    +
    +
    + + Policy flow edges + + + + + + {nodes.flatMap((node) => + node.children.map((child) => { + const sx = nodeX(node.col) + NODE_WIDTH; + const sy = nodeY(node.row) + NODE_HEIGHT / 2; + const ex = nodeX(child.col); + const ey = nodeY(child.row) + NODE_HEIGHT / 2; + const mx = (sx + ex) / 2; + const d = `M${sx},${sy} C${mx},${sy} ${mx},${ey} ${ex},${ey}`; + return ( + ${child.id}`} + markerEnd="url(#flow-tree-arrow)" + stroke="#cbd5e1" + strokeWidth={1.5} + /> + ); + }), + )} + + + {nodes.flatMap((node) => + node.children + .filter((c) => c.edgeLabel != null) + .map((child) => { + const sx = nodeX(node.col) + NODE_WIDTH; + const ex = nodeX(child.col); + const sy = nodeY(node.row) + NODE_HEIGHT / 2; + const ey = nodeY(child.row) + NODE_HEIGHT / 2; + const mx = (sx + ex) / 2; + const my = (sy + ey) / 2; + return ( + + {child.edgeLabel} + + ); + }), + )} + + {nodes.map((node) => ( +
    + + {nodeTypeLabel[node.type]} + + + {node.title} + + {node.subtitle != null && ( + + {node.subtitle} + + )} +
    + ))} +
    +
    +
    + ); +}; diff --git a/src/modules/flow/components/flowPrimitives/flowAddressLabel.tsx b/src/modules/flow/components/flowPrimitives/flowAddressLabel.tsx new file mode 100644 index 0000000000..dca208beb8 --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/flowAddressLabel.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { addressUtils } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import { useEnsName } from '@/modules/ens/hooks/useEnsName'; +import type { IFlowRecipient } from '../../types'; + +export interface IFlowAddressLabelProps { + /** + * EVM address to render. The component never emits the raw 42-char hex on its + * own — worst case it falls back to `addressUtils.truncateAddress`. + */ + address: string; + /** + * Pre-resolved human-readable label from `flowAddressBook` (e.g. "Treasury", + * "Burn address", "USDC stream"). When present the component skips the ENS + * lookup altogether — no point paying for a multicall RPC we won't render. + */ + knownLabel?: string | null; + /** + * Role hint from the address book — lets us tone the chip for burn addresses + * without every caller having to branch on the same string. + */ + knownRole?: IFlowRecipient['role']; + /** + * Synchronously-known ENS (e.g. DAO ENS). Rendered as a subtitle under the + * label when non-null and different from `knownLabel`. + */ + knownEns?: string | null; + /** + * Render the subtitle line (ENS / truncated address / role). Defaults to `true`; + * callers with tight layouts (e.g. card chips) can disable it. + */ + showSubtitle?: boolean; + /** + * When true the address is rendered as a link to the block explorer / etherscan + * — omitted by default because most call sites wrap the whole row in a link + * already. + */ + href?: string; + className?: string; +} + +const roleAccent: Record, string> = { + dao: 'text-primary-700', + linkedaccount: 'text-primary-600', + router: 'text-neutral-800', + subrouter: 'text-neutral-700', + burn: 'text-critical-700', +}; + +/** + * Renders one of: + * 1. `knownLabel` from the address book (synchronous, always wins). + * 2. A resolved ENS name via `useEnsName` (async, mainnet-only, multicall-batched). + * 3. Truncated address fallback — used while ENS resolves and when the address + * has no primary ENS set. + * + * The subtitle line shows a best-effort secondary identifier so power users can + * still copy the raw address when needed. + */ +export const FlowAddressLabel: React.FC = (props) => { + const { + address, + knownLabel, + knownRole, + knownEns, + showSubtitle = true, + href, + className, + } = props; + + // Skip the ENS fetch entirely when we already have a label — avoids spinning + // up a wagmi query (and the associated mainnet multicall) for addresses we + // know how to render without it. + const ensLookupAddress = knownLabel == null ? address : undefined; + const { data: resolvedEns, isLoading: ensLoading } = + useEnsName(ensLookupAddress); + + const truncated = addressUtils.truncateAddress(address); + + const primary = + (typeof knownLabel === 'string' && knownLabel.length > 0 + ? knownLabel + : null) ?? + resolvedEns ?? + truncated; + + const subtitle = (() => { + if (!showSubtitle) { + return null; + } + // When the primary label is the ENS we already showed, keep the subtitle + // as the raw truncated address so the user has a copy anchor. + if (primary === resolvedEns) { + return truncated; + } + // When the primary label is the address-book label, and we happen to + // have an ENS (either known or async-resolved), show that underneath. + const ens = knownEns ?? resolvedEns; + if (typeof ens === 'string' && ens.length > 0 && ens !== primary) { + return ens; + } + // Otherwise (known label without ENS) show the truncated address. + if (primary !== truncated) { + return truncated; + } + return null; + })(); + + const primaryClass = classNames( + 'truncate font-normal text-sm leading-tight', + knownRole ? roleAccent[knownRole] : 'text-neutral-800', + ); + + const content = ( + + {primary} + {subtitle != null && ( + + {ensLoading && primary === truncated + ? 'Resolving ENS…' + : subtitle} + + )} + + ); + + if (href != null) { + return ( + e.stopPropagation()} + rel="noopener noreferrer" + target="_blank" + > + {content} + + ); + } + + return content; +}; diff --git a/src/modules/flow/components/flowPrimitives/flowBlockieAvatar.tsx b/src/modules/flow/components/flowPrimitives/flowBlockieAvatar.tsx new file mode 100644 index 0000000000..6f97845d12 --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/flowBlockieAvatar.tsx @@ -0,0 +1,76 @@ +import classNames from 'classnames'; +import { useMemo } from 'react'; + +export interface IFlowBlockieAvatarProps { + address: string; + size?: number; + className?: string; +} + +const hashFn = (seed: string) => { + let x = 1_779_033_703 ^ seed.length; + for (let i = 0; i < seed.length; i += 1) { + x = Math.imul(x ^ seed.charCodeAt(i), 3_432_918_353); + x = (x << 13) | (x >>> 19); + } + return () => { + x = Math.imul(x ^ (x >>> 16), 2_246_822_507); + x = Math.imul(x ^ (x >>> 13), 3_266_489_909); + x ^= x >>> 16; + return (x >>> 0) / 4_294_967_296; + }; +}; + +const pickColor = (rand: () => number): string => { + const hues = [210, 24, 140, 280, 190, 340, 44, 100, 260]; + const hue = hues[Math.floor(rand() * hues.length)]; + const lightness = 55 + Math.floor(rand() * 15); + return `hsl(${hue}, 55%, ${lightness}%)`; +}; + +export const FlowBlockieAvatar: React.FC = (props) => { + const { address, size = 24, className } = props; + + const rects = useMemo(() => { + const rand = hashFn(address); + const primary = pickColor(rand); + const secondary = pickColor(rand); + const pixels: { x: number; y: number; color: string }[] = []; + for (let y = 0; y < 5; y += 1) { + for (let x = 0; x < 3; x += 1) { + const on = rand() > 0.5; + const color = on ? primary : secondary; + pixels.push({ x, y, color }); + if (x < 2) { + pixels.push({ x: 4 - x, y, color }); + } + } + } + return pixels; + }, [address]); + + return ( + + {rects.map((pixel) => ( + + ))} + + ); +}; diff --git a/src/modules/flow/components/flowPrimitives/flowStatusDot.tsx b/src/modules/flow/components/flowPrimitives/flowStatusDot.tsx new file mode 100644 index 0000000000..b0eda066e1 --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/flowStatusDot.tsx @@ -0,0 +1,63 @@ +import classNames from 'classnames'; +import type { FlowPolicyStatus } from '../../types'; + +export interface IFlowStatusDotProps { + status: FlowPolicyStatus; + className?: string; + /** + * When true the dot emits a soft pulse — used for live/ready states. + */ + pulse?: boolean; +} + +const statusToClass: Record = { + ready: 'bg-primary-400', + // `live` used to read as `success-500` (green); recolour to primary so + // the dashboard stays monochromatic and the "Ready" / "Live" pair + // forms a single highlight ramp. + live: 'bg-primary-300', + cooldown: 'bg-neutral-300', + awaiting: 'bg-neutral-400', + paused: 'bg-warning-600', + never: 'bg-neutral-300', +}; + +const statusLabel: Record = { + ready: 'Ready to dispatch', + live: 'Streaming', + cooldown: 'Cooldown', + awaiting: 'Awaiting proposal', + paused: 'Paused', + never: 'Never dispatched', +}; + +export const FlowStatusDot: React.FC = (props) => { + const { status, className, pulse } = props; + const shouldPulse = pulse ?? (status === 'ready' || status === 'live'); + return ( + + {shouldPulse && ( + + )} + + + ); +}; diff --git a/src/modules/flow/components/flowPrimitives/flowStrategyChip.tsx b/src/modules/flow/components/flowPrimitives/flowStrategyChip.tsx new file mode 100644 index 0000000000..02d7574da2 --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/flowStrategyChip.tsx @@ -0,0 +1,28 @@ +import classNames from 'classnames'; +import type { FlowPolicyStrategy } from '../../types'; + +export interface IFlowStrategyChipProps { + strategy: FlowPolicyStrategy; + className?: string; +} + +export const FlowStrategyChip: React.FC = (props) => { + const { strategy, className } = props; + // Multi-dispatch orchestrators are rendered inside the dedicated + // "Orchestrators" section / on their own detail route — the strategy chip + // would only restate that context, so hide it everywhere instead of + // sprinkling `strategy !== 'Multi-dispatch'` guards at each call site. + if (strategy === 'Multi-dispatch') { + return null; + } + return ( + + {strategy} + + ); +}; diff --git a/src/modules/flow/components/flowPrimitives/flowTokenChip.tsx b/src/modules/flow/components/flowPrimitives/flowTokenChip.tsx new file mode 100644 index 0000000000..a150e3969a --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/flowTokenChip.tsx @@ -0,0 +1,33 @@ +import classNames from 'classnames'; +import type { FlowTokenSymbol } from '../../types'; +import { getTokenColor } from '../../utils/flowFormatters'; + +export interface IFlowTokenChipProps { + token: FlowTokenSymbol; + className?: string; +} + +export const FlowTokenChip: React.FC = (props) => { + const { token, className } = props; + // Use the shared getter (not raw `FLOW_TOKENS[token]`) so unknown + // symbols — i.e. anything outside the curated USDC/MERC/WETH set, + // which is essentially every Envio-sourced token like stETH, wstETH, + // LDO — fall back to the neutral palette instead of crashing on + // `undefined.color`. + const color = getTokenColor(token); + return ( + + + {token} + + ); +}; diff --git a/src/modules/flow/components/flowPrimitives/flowWaitingForIndexerBadge.tsx b/src/modules/flow/components/flowPrimitives/flowWaitingForIndexerBadge.tsx new file mode 100644 index 0000000000..79e0ee21e1 --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/flowWaitingForIndexerBadge.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { ChainEntityType } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import type { Network } from '@/shared/api/daoService'; +import { useDaoChain } from '@/shared/hooks/useDaoChain'; +import type { IFlowPendingDispatch } from '../../providers/flowDataProvider'; + +export interface IFlowWaitingForIndexerBadgeProps { + /** + * Network of the DAO — used to resolve the explorer URL for the pending tx. + */ + network: string; + pending: IFlowPendingDispatch; + /** + * Visual density: + * - `compact` (default): a chip suited for a card footer / policy row. + * - `block`: larger banner used on the detail page header. + */ + variant?: 'compact' | 'block'; + className?: string; +} + +/** + * Persistent indicator shown from the moment a dispatch tx is confirmed in the + * wallet until the indexer catches up. Exists to reconcile two truths the user + * cares about: + * 1. "My transaction is on-chain" — we have a tx hash and a block. + * 2. "The dashboard still shows the old snapshot" — Envio hasn't run yet. + * + * Rather than faking an optimistic row in the feed we surface a dedicated + * "waiting for indexer" state so the user never sees drift between the live + * snapshot and the simulated one. + */ +export const FlowWaitingForIndexerBadge: React.FC< + IFlowWaitingForIndexerBadgeProps +> = (props) => { + const { network, pending, variant = 'compact', className } = props; + + const { buildEntityUrl } = useDaoChain({ network: network as Network }); + const txUrl = buildEntityUrl({ + type: ChainEntityType.TRANSACTION, + id: pending.txHash, + }); + + const containerClass = + variant === 'block' + ? 'flex items-center gap-2.5 rounded-xl border border-primary-100 bg-primary-50 px-3 py-2' + : 'inline-flex items-center gap-1.5 rounded-full bg-primary-50 px-2.5 py-1'; + + const labelClass = + variant === 'block' + ? 'font-semibold text-primary-800 text-sm leading-tight' + : 'font-semibold text-primary-800 text-xs leading-tight'; + + const subtitleClass = + variant === 'block' + ? 'font-normal text-primary-700 text-xs leading-tight' + : 'font-normal text-primary-700 text-[11px] leading-tight'; + + return ( +
    + +
    + + Dispatched · waiting for indexer + + {txUrl != null && ( + e.stopPropagation()} + rel="noopener noreferrer" + target="_blank" + > + View transaction ↗ + + )} +
    +
    + ); +}; + +const Spinner: React.FC<{ variant: 'compact' | 'block' }> = ({ variant }) => { + const size = variant === 'block' ? 'size-4' : 'size-3'; + return ( + + ); +}; diff --git a/src/modules/flow/components/flowPrimitives/index.ts b/src/modules/flow/components/flowPrimitives/index.ts new file mode 100644 index 0000000000..95141c8b0a --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/index.ts @@ -0,0 +1,12 @@ +export type { IFlowAddressLabelProps } from './flowAddressLabel'; +export { FlowAddressLabel } from './flowAddressLabel'; +export type { IFlowBlockieAvatarProps } from './flowBlockieAvatar'; +export { FlowBlockieAvatar } from './flowBlockieAvatar'; +export type { IFlowStatusDotProps } from './flowStatusDot'; +export { FlowStatusDot } from './flowStatusDot'; +export type { IFlowStrategyChipProps } from './flowStrategyChip'; +export { FlowStrategyChip } from './flowStrategyChip'; +export type { IFlowTokenChipProps } from './flowTokenChip'; +export { FlowTokenChip } from './flowTokenChip'; +export type { IFlowWaitingForIndexerBadgeProps } from './flowWaitingForIndexerBadge'; +export { FlowWaitingForIndexerBadge } from './flowWaitingForIndexerBadge'; diff --git a/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx b/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx new file mode 100644 index 0000000000..ea8a25e282 --- /dev/null +++ b/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx @@ -0,0 +1,475 @@ +'use client'; + +import classNames from 'classnames'; +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { useLmmManifest } from '../../demo/useLmmManifest'; +import { useFlowDataContext } from '../../providers/flowDataProvider'; +import type { + FlowTokenSymbol, + IFlowDaoData, + IFlowPolicy, + IFlowRecipient, +} from '../../types'; +import { formatFlowAmount, formatRelative } from '../../utils/flowFormatters'; +import { FlowAddressLabel, FlowBlockieAvatar } from '../flowPrimitives'; + +export type FlowRecipientsVariant = 'preview' | 'full' | 'policy'; + +export interface IFlowRecipientsTableProps { + data: IFlowDaoData; + variant?: FlowRecipientsVariant; + limit?: number; + policy?: IFlowPolicy; + className?: string; +} + +interface IRecipientRow { + address: string; + name: string; + ens?: string | null; + role?: IFlowRecipient['role']; + group: string; + fromPolicies: string[]; + amountsByToken: Partial>; + lastReceivedAt?: string; + dispatchCount?: number; + ratio?: string; + pct?: number; +} + +const parseRatioShare = (ratio: string | undefined): number | undefined => { + if (ratio == null) { + return undefined; + } + const parts = ratio + .split(':') + .map((p) => Number.parseFloat(p.trim())) + .filter((n) => Number.isFinite(n)); + if (parts.length < 2) { + return undefined; + } + const total = parts.reduce((sum, v) => sum + v, 0); + if (total === 0) { + return undefined; + } + return parts[0] / total; +}; + +const resolveShare = ( + recipient: IFlowRecipient, + fallbackCount: number, +): number => { + if (recipient.pct != null) { + return recipient.pct / 100; + } + const fromRatio = parseRatioShare(recipient.ratio); + if (fromRatio != null) { + return fromRatio; + } + return fallbackCount > 0 ? 1 / fallbackCount : 0; +}; + +const buildPolicyRows = (policy: IFlowPolicy): IRecipientRow[] => { + const recipients = policy.schema.recipients; + const successful = policy.dispatches.filter((d) => d.status !== 'failed'); + + return recipients.map((r) => { + const share = resolveShare(r, recipients.length); + let total = 0; + let lastReceivedAt: string | undefined; + let dispatchCount = 0; + + for (const dispatch of successful) { + total += dispatch.amount * share; + dispatchCount += 1; + if ( + lastReceivedAt == null || + new Date(dispatch.at).getTime() > + new Date(lastReceivedAt).getTime() + ) { + lastReceivedAt = dispatch.at; + } + } + + return { + address: r.address, + name: r.name, + ens: r.ens, + role: r.role, + group: policy.recipientGroup, + fromPolicies: [policy.name], + amountsByToken: total > 0 ? { [policy.token]: total } : {}, + ratio: r.ratio, + pct: r.pct ?? Number((share * 100).toFixed(2)), + lastReceivedAt, + dispatchCount, + }; + }); +}; + +const buildGlobalRows = (data: IFlowDaoData): IRecipientRow[] => + data.recipients.map((r) => ({ + address: r.address, + name: r.name, + ens: r.ens, + role: r.role, + group: r.group, + fromPolicies: r.fromPolicyIds.map( + (id) => data.policies.find((p) => p.id === id)?.name ?? id, + ), + amountsByToken: r.amountsByToken, + lastReceivedAt: r.lastReceivedAt, + dispatchCount: r.dispatchCount, + })); + +const formatAmounts = ( + amounts: Partial>, +): string => { + const parts = Object.entries(amounts) + .filter(([, v]) => v != null && v > 0) + .map( + ([token, value]) => + `${formatFlowAmount(value as number, token as FlowTokenSymbol)} ${token}`, + ); + return parts.length === 0 ? '—' : parts.join(' · '); +}; + +// --------------------------------------------------------------------------- +// LMM_DEMO_HACK: destinations-from-manifest +// +// Orchestrator policies don't have a 1:1 "recipient list" the way leaf +// routers do — every leg sends value to a different sink (DAO self, +// UniV2 pool, CowSwap settlement, buyback recipient). We synthesise the +// destination rows from the manifest (`lmm-manifest.json` → `lmm.dao`, +// `lido.agent`, `cowSwap.settlement`) and accumulate per-destination +// totals from `policy.dispatches` filtered by `legKind` (already emitted +// by the indexer via `execution.strategy.kind` — see +// envioFlowMapper.mapExecutionToDispatch). +// +// We deliberately don't traverse `orchestrator.runs[]` here: for the LMM +// demo the orchestrator's `subRouters` are *strategy contract addresses*, +// not indexer `Policy` rows, so `runs` is empty. The legs we need are +// the dispatcher policy's own dispatches. +// +// Production removal: emit explicit `recipient` on `ExecutionTransfer` for +// the synthetic destinations (LP recipient, CowSwap settlement) from the +// indexer side. See lido-mmd-status.md `destinations-from-manifest`. +// --------------------------------------------------------------------------- + +type LegKind = NonNullable; + +interface ILmmDestinationSeed { + address: string; + name: string; + role: IFlowRecipient['role']; + group: string; + legKinds: LegKind[]; +} + +const buildDestinationsRows = ( + policy: IFlowPolicy, + seeds: ILmmDestinationSeed[], +): IRecipientRow[] => { + if (seeds.length === 0) { + return []; + } + return seeds.map((seed) => { + const matching = policy.dispatches.filter( + (d) => + d.legKind != null && + seed.legKinds.includes(d.legKind) && + d.status !== 'failed', + ); + const amountsByToken: Partial> = {}; + let lastReceivedAt: string | undefined; + for (const d of matching) { + // Count `amount`/`token` (the dispatch's headline value) — not + // `amountIn`/`tokenIn`, which the swap/cow paths populate as a + // duplicate of the same number and would otherwise double-count. + // For non-swap legs (wrap/uniV2 leg-input fallback) `amountIn` is + // intentionally undefined, so this branch is the single source of + // truth across all leg kinds. + if (d.amount > 0 && d.token !== '') { + amountsByToken[d.token] = + (amountsByToken[d.token] ?? 0) + d.amount; + } + if ( + lastReceivedAt == null || + new Date(d.at).getTime() > new Date(lastReceivedAt).getTime() + ) { + lastReceivedAt = d.at; + } + } + return { + address: seed.address, + name: seed.name, + role: seed.role, + group: seed.group, + fromPolicies: [policy.name], + amountsByToken, + lastReceivedAt, + dispatchCount: matching.length, + }; + }); +}; + +const useLmmDestinationSeeds = ( + policy: IFlowPolicy | undefined, +): ILmmDestinationSeed[] => { + const { liveSnapshot } = useFlowDataContext(); + const { manifest } = useLmmManifest(); + if ( + policy == null || + manifest == null || + liveSnapshot?.dispatcherAddress == null || + policy.address.toLowerCase() !== + liveSnapshot.dispatcherAddress.toLowerCase() + ) { + return []; + } + const seeds: ILmmDestinationSeed[] = []; + seeds.push({ + address: manifest.lmm.dao, + name: 'LMM DAO (self · wstETH)', + role: 'dao', + group: 'Wrap', + legKinds: ['WRAP'], + }); + const lpRecipient = + // Lido Agent is the LP recipient on the demo deployment — see + // dao-launchpad/lido/preview/script/demo. Manifest exposes it + // as `lido.agent`. + manifest.lido?.agent ?? manifest.lmm.dao; + seeds.push({ + address: lpRecipient, + name: 'Lido Agent (UniV2 LP)', + role: 'linkedaccount', + group: 'UniV2 LP', + legKinds: ['UNIV2_LIQUIDITY'], + }); + const cowSwap = manifest.cowSwap?.settlement; + if (cowSwap) { + seeds.push({ + address: cowSwap, + name: 'CowSwap settlement', + role: 'router', + group: 'Buyback', + legKinds: ['GATED_COWSWAP', 'COWSWAP'], + }); + } + return seeds; +}; + +export const FlowRecipientsTable: React.FC = ( + props, +) => { + const { data, variant = 'full', limit, policy, className } = props; + + const lmmSeeds = useLmmDestinationSeeds(policy); + const isDestinationsVariant = variant === 'policy' && lmmSeeds.length > 0; + + const allRows = useMemo(() => { + if (variant === 'policy' && policy != null) { + if (lmmSeeds.length > 0) { + return buildDestinationsRows(policy, lmmSeeds); + } + return buildPolicyRows(policy); + } + return buildGlobalRows(data); + }, [data, variant, policy, lmmSeeds]); + + const [query, setQuery] = useState(''); + + const rows = useMemo(() => { + if (variant === 'preview') { + return allRows.slice(0, limit ?? 5); + } + if (query.trim() === '') { + return allRows; + } + const needle = query.toLowerCase(); + return allRows.filter( + (row) => + row.name.toLowerCase().includes(needle) || + row.address.toLowerCase().includes(needle), + ); + }, [allRows, variant, limit, query]); + + const title = isDestinationsVariant + ? 'Destinations' + : variant === 'policy' + ? 'Recipients' + : variant === 'preview' + ? 'Top recipients' + : 'Recipients'; + + return ( +
    +
    +

    + {title} +

    + {variant === 'preview' && ( + + View all → + + )} + {variant === 'full' && ( + setQuery(e.target.value)} + placeholder="Search recipients or addresses" + type="search" + value={query} + /> + )} +
    + +
    + + + + + {variant === 'policy' ? ( + <> + + + {!isDestinationsVariant && ( + + )} + + + ) : ( + <> + + + + + + )} + + + + {rows.map((row) => ( + + + {variant === 'policy' ? ( + <> + + + {!isDestinationsVariant && ( + + )} + + + ) : ( + <> + + + + + + )} + + ))} + {rows.length === 0 && ( + + + + )} + +
    + {isDestinationsVariant + ? 'Destination' + : 'Recipient'} + + {isDestinationsVariant + ? 'Cumulative routed' + : 'Total received'} + + Last routed + + # dispatches + + {isDestinationsVariant + ? 'Role' + : 'Share'} + + From policies + + Per-token totals + + Last received + + # dispatches +
    +
    + + +
    +
    + {formatAmounts(row.amountsByToken)} + + {row.lastReceivedAt != null + ? formatRelative( + row.lastReceivedAt, + ) + : '—'} + + {row.dispatchCount ?? 0} + + {isDestinationsVariant + ? row.group + : (row.ratio ?? + (row.pct != null + ? `${row.pct}%` + : '—'))} + + {row.fromPolicies.join(', ')} + + {formatAmounts(row.amountsByToken)} + + {row.lastReceivedAt != null + ? formatRelative( + row.lastReceivedAt, + ) + : '—'} + + {row.dispatchCount ?? '—'} +
    + {isDestinationsVariant + ? 'No destinations have received funds yet — the orchestrator hasn’t dispatched.' + : 'No recipients match the current filters.'} +
    +
    +
    + ); +}; diff --git a/src/modules/flow/components/flowSkeletons/flowSkeletons.tsx b/src/modules/flow/components/flowSkeletons/flowSkeletons.tsx new file mode 100644 index 0000000000..a397496c4c --- /dev/null +++ b/src/modules/flow/components/flowSkeletons/flowSkeletons.tsx @@ -0,0 +1,218 @@ +import classNames from 'classnames'; + +/** + * Loading skeletons for the Flow pages. Shapes mirror the real layouts so + * there's minimal CLS / visual jump when the Envio snapshot resolves and + * replaces the placeholder. + * + * Primitives (`Bar`, `Card`) are kept local — Flow is the only surface that + * needs them and they're too trivial to justify a shared component. + */ + +interface IBarProps { + className?: string; +} + +const Bar: React.FC = ({ className }) => ( +
    +); + +const Card: React.FC<{ + className?: string; + children?: React.ReactNode; +}> = ({ className, children }) => ( +
    + {children} +
    +); + +const LedeSkeleton: React.FC = () => ( +
    + + +
    +); + +const KpiRowSkeleton: React.FC = () => ( +
    + {Array.from({ length: 4 }).map((_, idx) => ( + + + + + + ))} +
    +); + +const PolicyCardSkeleton: React.FC = () => ( + +
    +
    + + +
    + +
    +
    + + +
    + +
    + + +
    +
    +); + +const PoliciesSectionSkeleton: React.FC = () => ( +
    +
    +
    + + + +
    + +
    +
    + {Array.from({ length: 3 }).map((_, idx) => ( + + ))} +
    +
    +); + +const ActivityFeedSkeleton: React.FC<{ rows?: number }> = ({ rows = 4 }) => ( +
    + + + {Array.from({ length: rows }).map((_, idx) => ( +
    + +
    + + +
    + +
    + ))} +
    +
    +); + +const RecipientsTableSkeleton: React.FC<{ rows?: number }> = ({ rows = 5 }) => ( + +
    + + + +
    + {Array.from({ length: rows }).map((_, idx) => ( +
    + +
    + + +
    + + +
    + ))} +
    +); + +export const FlowOverviewPageSkeleton: React.FC = () => ( +
    + + + + +
    +); + +export const FlowActivityPageSkeleton: React.FC = () => ( +
    +
    + + +
    + +
    +); + +export const FlowRecipientsPageSkeleton: React.FC = () => ( +
    +
    + + +
    + +
    +); + +/** + * Generic error banner shown on any Flow page when the Envio indexer query + * fails and we don't yet have a cached snapshot to fall back to. + */ +export const FlowLoadError: React.FC<{ message?: string }> = ({ message }) => ( +
    +

    + Couldn’t load Flow data +

    +

    + {message ?? + 'The capital flow indexer is unreachable. Refresh in a moment; if the problem persists, check the indexer status.'} +

    +
    +); + +export const FlowPolicyDetailPageSkeleton: React.FC = () => ( +
    +
    +
    +
    + + + +
    + +
    + + +
    +
    +
    + + +
    +
    + + + + +
    +); diff --git a/src/modules/flow/components/flowSkeletons/index.ts b/src/modules/flow/components/flowSkeletons/index.ts new file mode 100644 index 0000000000..f8bf0613b8 --- /dev/null +++ b/src/modules/flow/components/flowSkeletons/index.ts @@ -0,0 +1,7 @@ +export { + FlowActivityPageSkeleton, + FlowLoadError, + FlowOverviewPageSkeleton, + FlowPolicyDetailPageSkeleton, + FlowRecipientsPageSkeleton, +} from './flowSkeletons'; diff --git a/src/modules/flow/components/flowSparkline/flowSparkline.tsx b/src/modules/flow/components/flowSparkline/flowSparkline.tsx new file mode 100644 index 0000000000..4dfb1dfa01 --- /dev/null +++ b/src/modules/flow/components/flowSparkline/flowSparkline.tsx @@ -0,0 +1,284 @@ +'use client'; + +import { useMemo } from 'react'; +import { + Area, + ComposedChart, + ResponsiveContainer, + Scatter, + ScatterChart, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { FlowTokenSymbol, IFlowDispatch, IFlowEvent } from '../../types'; +import { + formatFlowAmount, + formatShortDate, + getTokenColor, +} from '../../utils/flowFormatters'; +import { + FLOW_EVENT_KIND_LABEL, + getFlowMarkerColor, + type IFlowTimelineMarker, +} from '../flowPolicyChart/flowEventStyles'; + +export interface IFlowSparklineProps { + dispatches: IFlowDispatch[]; + events: IFlowEvent[]; + installedAt: string; + token: FlowTokenSymbol; + /** + * Total vertical space (curve + timeline band). Defaults to a compact 72px + * that still leaves enough room to host a 2-tone event strip under the + * cumulative curve. + */ + height?: number; +} + +interface ISparklinePoint { + timestamp: number; + historic: number; +} + +interface ISparklineTimelinePoint { + x: number; + y: number; + marker: IFlowTimelineMarker; + fill: string; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const FlowSparkline: React.FC = (props) => { + const { dispatches, events, installedAt, token, height = 72 } = props; + + const { data, markers, minTs, maxTs } = useMemo(() => { + const sorted = [...dispatches].sort( + (a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(), + ); + const installedAtMs = new Date(installedAt).getTime(); + // Use the last dispatch (or install) as the "now" anchor so the + // render stays deterministic between SSR and first client paint — + // `Date.now()` would drift between the two. + const lastDispatchMs = sorted.length + ? new Date(sorted.at(-1)!.at).getTime() + : installedAtMs; + const lastEventMs = events.length + ? Math.max(...events.map((e) => new Date(e.at).getTime())) + : installedAtMs; + const nowTs = Math.max(installedAtMs, lastDispatchMs, lastEventMs); + + const span = Math.max(nowTs - installedAtMs, DAY_MS); + const pad = Math.max(DAY_MS, span * 0.03); + + const points: ISparklinePoint[] = [ + { timestamp: installedAtMs, historic: 0 }, + ]; + const dispatchMarkers: IFlowTimelineMarker[] = []; + let cumulative = 0; + for (const dispatch of sorted) { + const ts = new Date(dispatch.at).getTime(); + if (dispatch.status !== 'failed') { + cumulative += dispatch.amount; + } + points.push({ timestamp: ts, historic: cumulative }); + dispatchMarkers.push({ + id: `dispatch-${dispatch.id}`, + timestamp: ts, + kind: + dispatch.status === 'failed' + ? 'dispatchFailed' + : 'dispatchOk', + label: + dispatch.status === 'failed' + ? 'Failed dispatch' + : 'Dispatch', + detail: `${formatFlowAmount(dispatch.amount, dispatch.token)} ${dispatch.token} · ${formatShortDate(dispatch.at)}`, + }); + } + // Trailing flat segment so the area extends to the right edge of the + // domain instead of stopping at the last tick. + points.push({ timestamp: nowTs, historic: cumulative }); + + const eventMarkers: IFlowTimelineMarker[] = events.map((event) => ({ + id: `event-${event.id}`, + timestamp: new Date(event.at).getTime(), + kind: event.kind, + label: FLOW_EVENT_KIND_LABEL[event.kind], + detail: `${event.title} · ${formatShortDate(event.at)}`, + })); + + const allMarkers = [...dispatchMarkers, ...eventMarkers].sort( + (a, b) => a.timestamp - b.timestamp, + ); + + return { + data: points, + markers: allMarkers, + minTs: installedAtMs - pad, + maxTs: nowTs + pad, + }; + }, [dispatches, events, installedAt]); + + const color = getTokenColor(token); + const id = `flowSparkGradient-${token}`; + + // Reserve a constant slice for the timeline band so the curve stays + // visually stable across cards regardless of `height` prop. 24px fits a + // single row of 6px dots plus a bit of padding. + const timelineHeight = 24; + const areaHeight = Math.max(height - timelineHeight, 32); + + const scatterData: ISparklineTimelinePoint[] = markers.map((marker) => ({ + x: marker.timestamp, + y: 0.5, + marker, + fill: getFlowMarkerColor(marker.kind, color), + })); + + return ( +
    +
    + {/* Seed `initialDimension` so recharts' first render doesn't + * log a width(-1)/height(-1) dev warning before its + * ResizeObserver fires. The container is exactly + * `areaHeight` tall; width is filled in on the next frame. */} + + + + + + + + + + + + + +
    +
    + + + + + { + const payload = tooltipProps.payload as + | ReadonlyArray<{ + payload: ISparklineTimelinePoint; + }> + | undefined; + if ( + tooltipProps.active !== true || + payload == null || + payload.length === 0 + ) { + return null; + } + const { marker, fill } = payload[0].payload; + return ( +
    +
    + + + {marker.label} + +
    + + {marker.detail} + +
    + ); + }} + cursor={false} + isAnimationActive={false} + /> + { + const { cx, cy, payload } = shapeProps as { + cx?: number; + cy?: number; + payload?: ISparklineTimelinePoint; + }; + if ( + cx == null || + cy == null || + payload == null + ) { + return ; + } + return ( + + ); + }} + /> +
    +
    +
    +
    + ); +}; diff --git a/src/modules/flow/components/flowSubNav/flowSubNav.tsx b/src/modules/flow/components/flowSubNav/flowSubNav.tsx new file mode 100644 index 0000000000..f13a5cd2ab --- /dev/null +++ b/src/modules/flow/components/flowSubNav/flowSubNav.tsx @@ -0,0 +1,96 @@ +'use client'; + +import classNames from 'classnames'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +export interface IFlowSubNavProps { + network: string; + addressOrEns: string; + className?: string; +} + +interface IFlowTab { + key: string; + label: string; + match: (pathname: string, base: string) => boolean; + href: (base: string) => string; +} + +/** Dashboard-vision sub-tabs. The Dashboard ⇄ Canvas and Workbench ⇄ Focus + * switches live in the topbar (next to the wallet); this row is only the + * dashboard sections and is hidden entirely on the canvas single-pager. */ +const FLOW_TABS: IFlowTab[] = [ + { + key: 'overview', + label: 'Overview', + match: (pathname, base) => + pathname === base || + pathname === `${base}/` || + pathname.startsWith(`${base}/policies`), + href: (base) => base, + }, + { + key: 'activity', + label: 'Activity', + match: (pathname, base) => pathname.startsWith(`${base}/activity`), + href: (base) => `${base}/activity`, + }, + { + key: 'recipients', + label: 'Recipients', + match: (pathname, base) => pathname.startsWith(`${base}/recipients`), + href: (base) => `${base}/recipients`, + }, +]; + +export const FlowSubNav: React.FC = (props) => { + const { network, addressOrEns, className } = props; + const pathname = usePathname() ?? ''; + const base = `/dao/${network}/${addressOrEns}/flow`; + + // Canvas is a full-bleed single-pager — its layout switch is in the topbar, + // so there's no sub-nav row to show here. + if (pathname.startsWith(`${base}/workbench`)) { + return null; + } + + return ( +
    + +
    + ); +}; diff --git a/src/modules/flow/components/flowToastStack/flowToastStack.tsx b/src/modules/flow/components/flowToastStack/flowToastStack.tsx new file mode 100644 index 0000000000..cfc2565b08 --- /dev/null +++ b/src/modules/flow/components/flowToastStack/flowToastStack.tsx @@ -0,0 +1,49 @@ +'use client'; + +import classNames from 'classnames'; +import { useFlowDataContext } from '../../providers/flowDataProvider'; + +const toneClass: Record<'success' | 'info' | 'error' | 'warning', string> = { + success: 'border-success-300 bg-success-50 text-success-800', + info: 'border-neutral-200 bg-neutral-0 text-neutral-800', + error: 'border-critical-300 bg-critical-50 text-critical-800', + warning: 'border-warning-300 bg-warning-50 text-warning-800', +}; + +export const FlowToastStack: React.FC = () => { + const { toasts, dismissToast } = useFlowDataContext(); + + if (toasts.length === 0) { + return null; + } + + return ( +
    + {toasts.map((toast) => ( + + ))} +
    + ); +}; diff --git a/src/modules/flow/components/flowTopbar/flowTopbar.tsx b/src/modules/flow/components/flowTopbar/flowTopbar.tsx new file mode 100644 index 0000000000..a720c24ac4 --- /dev/null +++ b/src/modules/flow/components/flowTopbar/flowTopbar.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { DaoAvatar, Wallet } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useConnection } from 'wagmi'; +import { ApplicationDialogId } from '@/modules/application/constants/applicationDialogId'; +import { useDao } from '@/shared/api/daoService'; +import { AragonLogo } from '@/shared/components/aragonLogo'; +import { useDialogContext } from '@/shared/components/dialogProvider'; +import { useIsMounted } from '@/shared/hooks/useIsMounted'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { ipfsUtils } from '@/shared/utils/ipfsUtils'; +import { MmIcon } from '../flowWorkbench/mmIcon'; + +export interface IFlowTopbarProps { + daoId: string; + network: string; + addressOrEns: string; + className?: string; +} + +interface IIconSegment { + key: string; + href: string; + active: boolean; + icon: string; + title: string; +} + +/** Compact icon-only segmented control (links) — sits beside the wallet button + * so the vision/layout switches don't eat a whole nav row. */ +const IconSegmented: React.FC<{ label: string; segments: IIconSegment[] }> = ({ + label, + segments, +}) => ( +
    + {segments.map((seg) => ( + + + + ))} +
    +); + +/** Dashboard ⇄ Canvas (the live flow cockpit) vision switch. */ +const FlowViewToggles: React.FC<{ network: string; addressOrEns: string }> = ({ + network, + addressOrEns, +}) => { + const pathname = usePathname() ?? ''; + const base = `/dao/${network}/${addressOrEns}/flow`; + const isCanvas = pathname.startsWith(`${base}/workbench`); + + return ( + + ); +}; + +export const FlowTopbar: React.FC = (props) => { + const { daoId, network, addressOrEns, className } = props; + + const { data: dao } = useDao({ urlParams: { id: daoId } }); + const { address, isConnected } = useConnection(); + const isMounted = useIsMounted(); + const effectiveIsConnected = isMounted && isConnected; + const { open } = useDialogContext(); + + const daoDisplayName = dao != null ? daoUtils.getDaoDisplayName(dao) : ''; + const daoAvatar = + dao?.avatar != null ? ipfsUtils.cidToSrc(dao.avatar) : undefined; + const walletUser = isMounted && address != null ? { address } : undefined; + + const handleWalletClick = () => { + const dialog = effectiveIsConnected + ? ApplicationDialogId.USER + : ApplicationDialogId.CONNECT_WALLET; + open(dialog); + }; + + return ( +
    +
    +
    + {/* "Aragon Flow" wordmark: AragonLogo renders the brand + * "Aragon" wordmark in `text-primary-400` (currentColor + * on the SVG paths), and we append a matching "Flow" + * suffix so the pair reads as a single product name. */} + + + + Flow + + + / + + + + {daoDisplayName || addressOrEns} + + +
    +
    + + +
    +
    +
    + ); +}; diff --git a/src/modules/flow/components/flowWorkbench/buildWorkbenchModel.test.ts b/src/modules/flow/components/flowWorkbench/buildWorkbenchModel.test.ts new file mode 100644 index 0000000000..751b436cd9 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/buildWorkbenchModel.test.ts @@ -0,0 +1,360 @@ +import type { + IFlowDynamics, + IFlowMachineDescriptor, +} from '../../canvas/flowGraphTypes'; +import type { + IFlowDaoData, + IFlowOrchestrator, + IFlowOrchestratorRun, +} from '../../types'; +import { + buildFlowOptions, + buildHistory, + buildNextRun, + buildRecipientHint, + buildStats, + toRunDynamics, +} from './buildWorkbenchModel'; + +const run = ( + id: string, + legs: IFlowOrchestratorRun['legs'], + at = '2026-06-01T00:00:00.000Z', +): IFlowOrchestratorRun => ({ id, at, txHash: `0x${id}abcdef`, legs }); + +const orchestrator = ( + over: Partial = {}, +): IFlowOrchestrator => ({ + id: 'orch-1', + address: '0xDISPATCHER', + name: 'Lido Money Machine', + description: '', + strategy: 'Multi-dispatch', + status: 'live', + statusLabel: 'Live', + createdAt: '2026-05-01T00:00:00.000Z', + installTxHash: '0xinstall', + chain: [], + runs: [], + totalRuns: 0, + ...over, +}); + +const NOW = Date.parse('2026-06-02T00:00:00.000Z'); + +describe('buildWorkbenchModel', () => { + describe('buildFlowOptions', () => { + it('maps each orchestrator to an option, marking run ones live', () => { + const data = { + orchestrators: [ + orchestrator({ + id: 'a', + name: 'A', + totalRuns: 3, + embeddedStrategies: [ + { index: 0 }, + { index: 1 }, + ] as IFlowOrchestrator['embeddedStrategies'], + }), + orchestrator({ + id: 'b', + name: 'B', + totalRuns: 0, + chain: [null, null, null], + }), + ], + } as IFlowDaoData; + + const options = buildFlowOptions(data); + expect(options).toEqual([ + { + id: 'a', + name: 'A', + type: 'Multi-dispatch', + strategies: 2, + status: 'live', + }, + { + id: 'b', + name: 'B', + type: 'Multi-dispatch', + strategies: 3, + status: 'never run', + }, + ]); + }); + }); + + describe('buildRecipientHint', () => { + it('picks the most-dispatched non-DAO recipient', () => { + const data = { + recipients: [ + { + address: '0xdao', + name: 'DAO', + role: 'dao', + dispatchCount: 99, + }, + { + address: '0xagent', + name: 'Agent', + role: 'linkedaccount', + dispatchCount: 12, + }, + { + address: '0xother', + name: 'Other', + dispatchCount: 3, + }, + ], + } as unknown as IFlowDaoData; + + expect(buildRecipientHint(data)).toEqual({ + label: 'Agent', + address: '0xagent', + }); + }); + + it('returns undefined when there are no non-DAO recipients', () => { + const data = { + recipients: [ + { + address: '0xdao', + name: 'DAO', + role: 'dao', + dispatchCount: 1, + }, + ], + } as unknown as IFlowDaoData; + expect(buildRecipientHint(data)).toBeUndefined(); + }); + }); + + describe('buildStats', () => { + it('aggregates token-only moved/buyback totals and success rate', () => { + const o = orchestrator({ + totalRuns: 3, + runs: [ + run('1', [ + { + policyId: 'p', + policyName: 'Wrap', + strategy: 'Stream', + amountOut: 100, + tokenOut: 'stETH', + recipientsCount: 1, + status: 'ok', + }, + { + policyId: 'p2', + policyName: 'Buyback', + strategy: 'CoW swap', + amountOut: 600, + tokenOut: 'LDO', + recipientsCount: 1, + status: 'ok', + }, + ]), + run('2', [ + { + policyId: 'p2', + policyName: 'Buyback', + strategy: 'CoW swap', + amountOut: 400, + tokenOut: 'LDO', + recipientsCount: 1, + status: 'skipped', + }, + ]), + ], + }); + + const stats = buildStats(o, NOW); + expect(stats.dispatches).toBe(3); + // run 1 ok, run 2 has a skipped leg → partial. + expect(stats.successRate).toBeCloseTo(0.5); + expect(stats.totalMoved).toEqual([ + { token: 'stETH', amount: 100 }, + { token: 'LDO', amount: 600 }, + ]); + // Skipped leg excluded; only the ok CoW leg counts as a buyback. + expect(stats.buybacks).toEqual([{ token: 'LDO', amount: 600 }]); + expect(stats.activeSinceDays).toBeGreaterThanOrEqual(30); + }); + }); + + describe('buildHistory', () => { + it('labels runs by descending number and derives status from legs', () => { + const o = orchestrator({ + totalRuns: 2, + runs: [ + run('newest', [ + { + policyId: 'p', + policyName: 'Wrap', + strategy: 'Stream', + amountOut: 1, + tokenOut: 'wstETH', + recipientsCount: 1, + status: 'failed', + }, + ]), + run('older', [ + { + policyId: 'p', + policyName: 'Wrap', + strategy: 'Stream', + amountOut: 2, + tokenOut: 'wstETH', + recipientsCount: 1, + status: 'ok', + }, + ]), + ], + }); + const history = buildHistory(o, NOW); + expect(history.map((h) => h.label)).toEqual(['#2', '#1']); + expect(history[0].status).toBe('failed'); + expect(history[1].status).toBe('ok'); + expect(history[0].legs?.[0]).toMatchObject({ + kind: 'Stream', + failed: true, + }); + }); + }); + + describe('toRunDynamics', () => { + const descriptor: IFlowMachineDescriptor = { + id: 'orch-1', + source: { label: 'Vault' }, + steps: [ + { + index: 0, + address: '0xwrap', + kind: 'wrap', + label: 'Wrap', + paused: false, + inputs: [], + }, + { + index: 1, + address: '0xcow', + kind: 'gatedCowSwap', + label: 'Buyback', + paused: false, + inputs: [], + }, + ], + }; + + it('projects leg statuses + amounts onto step dynamics by order', () => { + const r = run('r1', [ + { + policyId: 'p', + policyName: 'Wrap', + strategy: 'Stream', + amountIn: 100, + tokenIn: 'stETH', + amountOut: 85, + tokenOut: 'wstETH', + recipientsCount: 1, + status: 'ok', + }, + { + policyId: 'p2', + policyName: 'Buyback', + strategy: 'CoW swap', + amountOut: 600, + tokenOut: 'LDO', + recipientsCount: 1, + status: 'skipped', + }, + ]); + const dyn = toRunDynamics(descriptor, r); + expect(dyn.runId).toBe('r1'); + expect(dyn.steps[0]).toMatchObject({ + address: '0xwrap', + state: 'done', + outs: [{ token: 'wstETH', amount: 85, fidelity: 'real' }], + ins: [{ token: 'stETH', amount: 100, fidelity: 'real' }], + }); + expect(dyn.steps[1]).toMatchObject({ + address: '0xcow', + state: 'skipped', + }); + }); + + it('marks steps with no matching leg as idle', () => { + const r = run('r2', []); + const dyn = toRunDynamics(descriptor, r); + expect(dyn.steps.every((s) => s.state === 'idle')).toBe(true); + }); + }); + + describe('buildNextRun', () => { + it('summarizes fire/skip per step and resolves labels by address', () => { + const descriptor: IFlowMachineDescriptor = { + id: 'orch-1', + source: { label: 'Vault' }, + steps: [ + { + index: 0, + address: '0xwrap', + kind: 'wrap', + label: 'Wrap', + paused: false, + inputs: [], + }, + { + index: 1, + address: '0xcow', + kind: 'gatedCowSwap', + label: 'Buyback', + paused: false, + inputs: [], + }, + ], + }; + const dynamics: IFlowDynamics = { + steps: [ + { + address: '0xwrap', + index: 0, + state: 'firing', + ins: [ + { token: 'stETH', amount: 100, fidelity: 'real' }, + ], + outs: [ + { + token: 'wstETH', + amount: 85, + fidelity: 'estimated', + }, + ], + inputReadings: [{ epoch: 47_123 }], + }, + { + address: '0xcow', + index: 1, + state: 'blocked', + skipReason: 'gate closed', + outs: undefined, + }, + ], + }; + const next = buildNextRun(descriptor, dynamics); + expect(next.epoch).toBe(47_123); + expect(next.summary).toBe('Wrap · Buyback skipped'); + expect(next.steps[0]).toMatchObject({ + kind: 'Wrap', + willFire: true, + }); + expect(next.steps[1]).toMatchObject({ + kind: 'Buyback', + willFire: false, + skipReason: 'gate closed', + outs: [{ token: '?', amount: null, fidelity: 'opaque' }], + }); + }); + }); +}); diff --git a/src/modules/flow/components/flowWorkbench/buildWorkbenchModel.ts b/src/modules/flow/components/flowWorkbench/buildWorkbenchModel.ts new file mode 100644 index 0000000000..30c1101f36 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/buildWorkbenchModel.ts @@ -0,0 +1,363 @@ +/** + * Pure builders that turn the generic flow data (`IFlowDaoData` + an optional + * live-dynamics overlay) into the workbench's presentational view-model. + * + * Token-only, USD-free, and flow-shape-agnostic: a flow is any orchestrator, + * stats/history come from its runs, and the next-run summary is derived from + * the same live dynamics the canvas uses. Replay dynamics are projected from a + * recorded run's legs. Nothing here is LMM-specific. + */ + +import { MEANINGFUL_AMOUNT_EPS } from '../../canvas/buildFlowGraph'; +import type { + IFlowDynamics, + IFlowIndexedStep, + IFlowMachineDescriptor, + IFlowStepDynamics, +} from '../../canvas/flowGraphTypes'; +import type { + IFlowDaoData, + IFlowOrchestrator, + IFlowOrchestratorRun, +} from '../../types'; +import { formatRelative } from '../../utils/flowFormatters'; +import type { + IFlowOption, + IHistoryLeg, + IHistoryRun, + INextRun, + ITokenAmount, + ITokenFlow, + IWorkbenchStats, + RunStatus, +} from './workbenchModel'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Human-language description of a flow. */ +export interface IFlowDescription { + title: string; + summary: string; + /** One short verb-phrase per strategy, in pipeline order. */ + steps: string[]; +} + +// Per-kind verb phrase for the auto-generated summary. NOTE: this is a +// structural fallback derived from the strategy taxonomy — once flows carry an +// author-written purpose (added at creation time) we render that instead. +const KIND_PHRASE: Record = { + wrap: 'wraps the staking token', + univ2Liquidity: 'provides Uniswap V2 liquidity', + gatedCowSwap: 'runs a price-gated buyback via CoW Swap', + cowSwap: 'buys back tokens via CoW Swap', + transfer: 'transfers tokens to recipients', + epochTransfer: 'streams tokens each epoch', + burn: 'burns tokens', + unknown: 'runs a custom strategy', +}; + +/** + * Build a human-readable description of what a flow does, generated from its + * strategy pipeline. Display-first placeholder until author-written purpose + * metadata exists. + */ +export const buildFlowDescription = ( + orchestrator: IFlowOrchestrator, +): IFlowDescription => { + const ordered = [...(orchestrator.embeddedStrategies ?? [])].sort( + (a, b) => a.index - b.index, + ); + const steps = ordered.map( + (s) => KIND_PHRASE[s.kind] ?? KIND_PHRASE.unknown, + ); + const summary = + steps.length > 0 + ? `Automates the DAO treasury: ${steps.join(', then ')} — with proceeds returning to the vault.` + : 'An on-chain capital-flow automation for the DAO treasury.'; + return { title: orchestrator.name, summary, steps }; +}; + +/** Each orchestrator is a flow. Status is "live" once it has run. */ +export const buildFlowOptions = (data: IFlowDaoData): IFlowOption[] => + data.orchestrators.map((o) => ({ + id: o.id, + name: o.name, + type: o.strategy, + strategies: o.embeddedStrategies?.length ?? o.chain.length, + status: o.totalRuns > 0 ? 'live' : 'never run', + })); + +/** Top non-DAO recipient across the DAO, used as the terminal hint. */ +export const buildRecipientHint = ( + data: IFlowDaoData, +): { label: string; address?: string } | undefined => { + const candidate = [...data.recipients] + .filter((r) => r.role !== 'dao') + .sort((a, b) => b.dispatchCount - a.dispatchCount)[0]; + if (!candidate) { + return undefined; + } + return { label: candidate.name, address: candidate.address }; +}; + +const sumByToken = (entries: ITokenAmount[]): ITokenAmount[] => { + const map = new Map(); + for (const e of entries) { + if (e.amount == null) { + continue; + } + map.set(e.token, (map.get(e.token) ?? 0) + e.amount); + } + return [...map.entries()].map(([token, amount]) => ({ token, amount })); +}; + +const runStatus = (run: IFlowOrchestratorRun): RunStatus => { + if (run.legs.some((l) => l.status === 'failed')) { + return 'failed'; + } + if (run.legs.some((l) => l.status === 'skipped')) { + return 'partial'; + } + return 'ok'; +}; + +/** + * Per-token in/out throughput across every run, projected from the indexed + * provenance edges (real settled amounts). `outAmount` = left the vault + * (vault-out + external); `inAmount` = produced back to the vault (vault-in). + * Opaque inflows (e.g. LP pre-settlement) are flagged rather than summed as 0. + */ +const buildTokenFlows = (steps: readonly IFlowIndexedStep[]): ITokenFlow[] => { + const byToken = new Map(); + const entry = (token: string): ITokenFlow => { + const found = byToken.get(token); + if (found) { + return found; + } + const created: ITokenFlow = { token, inAmount: 0, outAmount: 0 }; + byToken.set(token, created); + return created; + }; + for (const step of steps) { + for (const edge of step.edges) { + const row = entry(edge.token); + if (edge.role === 'vaultIn') { + if (edge.amount == null) { + row.opaqueIn = true; + } else { + row.inAmount += edge.amount; + } + } else if (edge.amount != null) { + // vaultOut + external — value that left the vault. + row.outAmount += edge.amount; + } + } + } + return [...byToken.values()] + .filter((f) => f.inAmount > 0 || f.outAmount > 0 || f.opaqueIn) + .sort((a, b) => b.inAmount + b.outAmount - (a.inAmount + a.outAmount)); +}; + +export const buildStats = ( + orchestrator: IFlowOrchestrator, + now: number, +): IWorkbenchStats => { + const runs = orchestrator.runs; + const successful = runs.filter((r) => runStatus(r) === 'ok').length; + const moved: ITokenAmount[] = []; + const buybacks: ITokenAmount[] = []; + for (const run of runs) { + for (const leg of run.legs) { + if (leg.status !== 'ok' || leg.amountOut == null) { + continue; + } + moved.push({ token: leg.tokenOut, amount: leg.amountOut }); + if ( + leg.strategy === 'CoW swap' || + leg.strategy === 'Uniswap swap' + ) { + buybacks.push({ token: leg.tokenOut, amount: leg.amountOut }); + } + } + } + const allIndexedSteps = runs.flatMap((r) => r.indexedSteps ?? []); + const createdMs = Date.parse(orchestrator.createdAt); + const activeSinceDays = Number.isFinite(createdMs) + ? Math.max(1, Math.round((now - createdMs) / DAY_MS)) + : 0; + return { + dispatches: orchestrator.totalRuns, + activeSinceDays, + successRate: runs.length > 0 ? successful / runs.length : 0, + totalMoved: sumByToken(moved), + buybacks: sumByToken(buybacks), + tokenFlows: buildTokenFlows(allIndexedSteps), + }; +}; + +const shortTx = (txHash: string): string => + txHash.length > 10 ? `${txHash.slice(0, 6)}…` : txHash; + +export const buildHistory = ( + orchestrator: IFlowOrchestrator, + now: number, +): IHistoryRun[] => + orchestrator.runs.map((run, i) => { + const legs: IHistoryLeg[] = run.legs.map((leg) => ({ + kind: leg.strategy, + ok: leg.status === 'ok', + skipped: leg.status === 'skipped', + failed: leg.status === 'failed', + detail: + leg.amountOut != null + ? `${leg.amountOut.toLocaleString('en-US', { maximumFractionDigits: 2 })} ${leg.tokenOut}` + : undefined, + })); + return { + run: run.id, + label: `#${orchestrator.totalRuns - i}`, + at: formatRelative(run.at, now), + status: runStatus(run), + legs, + tx: shortTx(run.txHash), + }; + }); + +/** + * Next-dispatch summary derived from the live dynamics (so the modal and the + * canvas agree). `kind` labels are resolved from the descriptor by address. + */ +export const buildNextRun = ( + descriptor: IFlowMachineDescriptor, + dynamics: IFlowDynamics, +): INextRun => { + const labelFor = (address: string): string => + descriptor.steps.find( + (s) => s.address.toLowerCase() === address.toLowerCase(), + )?.label ?? 'Strategy'; + + const moves = (a: number | null): boolean => + a == null || Math.abs(a) > MEANINGFUL_AMOUNT_EPS; + const steps = dynamics.steps.map((s) => { + const kind = labelFor(s.address); + // "Firing" only counts if a meaningful amount actually moves — a leg + // that fires on dust (1 wei) reads as a no-op, matching the canvas. + const willFire = + s.state === 'firing' && + ((s.ins ?? []).some((i) => moves(i.amount)) || + (s.outs ?? []).some((o) => moves(o.amount))); + return { + kind, + willFire, + ins: (s.ins ?? []).map((i) => ({ + token: i.token, + amount: i.amount, + fidelity: i.fidelity, + })), + outs: + s.outs && s.outs.length > 0 + ? s.outs.map((o) => ({ + token: o.token, + amount: o.amount, + fidelity: o.fidelity, + })) + : [ + { + token: '?', + amount: null, + fidelity: 'opaque' as const, + }, + ], + skipReason: willFire ? undefined : s.skipReason, + }; + }); + + const epochReading = dynamics.steps + .flatMap((s) => s.inputReadings ?? []) + .find((r) => r.epoch != null)?.epoch; + + const summary = steps + .map((s) => (s.willFire ? s.kind : `${s.kind} skipped`)) + .join(' · '); + + // Net to the DAO after this dispatch: returns (outs that loop back, i.e. + // not flagged external) minus draws (ins). Mirrors the canvas vault node. + const net = new Map(); + const bump = (token: string, signed: number, opaque: boolean) => { + const e = net.get(token) ?? { delta: 0, opaque: false }; + e.delta += signed; + e.opaque = e.opaque || opaque; + net.set(token, e); + }; + for (const s of dynamics.steps) { + if (s.state !== 'firing') { + continue; + } + for (const i of s.ins ?? []) { + bump(i.token, i.amount == null ? 0 : -i.amount, i.amount == null); + } + for (const o of s.outs ?? []) { + if (o.external) { + continue; + } + bump(o.token, o.amount == null ? 0 : o.amount, o.amount == null); + } + } + const netEntries = [...net.entries()] + .filter( + ([, v]) => Math.abs(v.delta) > MEANINGFUL_AMOUNT_EPS || v.opaque, + ) + .map(([token, v]) => ({ + token, + delta: v.delta, + opaque: v.opaque || undefined, + })); + + return { epoch: epochReading, summary, steps, net: netEntries }; +}; + +/** Map a recorded run's legs onto replay dynamics, matched to steps by order. */ +export const toRunDynamics = ( + descriptor: IFlowMachineDescriptor, + run: IFlowOrchestratorRun, +): IFlowDynamics => { + const steps: IFlowStepDynamics[] = descriptor.steps.map( + (descriptorStep, i) => { + const leg = run.legs[i]; + const state = + leg == null + ? 'idle' + : leg.status === 'failed' + ? 'failed' + : leg.status === 'skipped' + ? 'skipped' + : 'done'; + return { + address: descriptorStep.address, + index: descriptorStep.index, + state, + ins: + leg?.tokenIn != null && leg.amountIn != null + ? [ + { + token: leg.tokenIn, + amount: leg.amountIn, + fidelity: 'real' as const, + }, + ] + : undefined, + outs: + leg != null && leg.amountOut != null + ? [ + { + token: leg.tokenOut, + amount: leg.amountOut, + fidelity: 'real' as const, + }, + ] + : undefined, + }; + }, + ); + return { runId: run.id, steps }; +}; diff --git a/src/modules/flow/components/flowWorkbench/flowCanvas.tsx b/src/modules/flow/components/flowWorkbench/flowCanvas.tsx new file mode 100644 index 0000000000..7a8afbaee9 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/flowCanvas.tsx @@ -0,0 +1,972 @@ +'use client'; + +/** + * FlowCanvas — the workbench hero. A fixed-coordinate stage (sized by the + * layout engine) that auto-fits its container; absolutely-positioned node + * modules + an SVG edge layer with fidelity-aware styling and a single + * travelling dot per active edge. + * + * Pure presentation over a positioned {@link IFlowGraph}: it reads node + * geometry (x/y/w/h) and edge fidelity/state, and knows nothing about how the + * graph was built (live snapshot vs. replayed run). Styled with Tailwind on + * kit tokens; SVG strokes reference the kit's `--color-*` CSS variables. + */ + +import classNames from 'classnames'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + FlowFidelity, + IFlowGraph, + IFlowGraphEdge, + IFlowGraphNode, + IFlowNetEntry, + IFlowSubInput, +} from '../../canvas/flowGraphTypes'; +import { + getStrategyDisplay, + getSubInputDisplay, +} from '../../canvas/primitiveRegistry'; +import { MmIcon } from './mmIcon'; +import { Amount, Pill } from './mmPrimitives'; +import { MM_STATES, toneChip, tonePulseVar } from './tone'; + +const REDUCED = + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +type Side = 'left' | 'right'; + +interface IPoint { + x: number; + y: number; +} + +interface IEdgeAnchors { + a: IPoint; + b: IPoint; + aSide: Side; + bSide: Side; +} + +const centerY = (node: IFlowGraphNode): number => node.y + node.h / 2; +const centerX = (node: IFlowGraphNode): number => node.x + node.w / 2; + +/** + * The vertical band a node exposes connection anchors along, per side. Legs keep + * anchors near the card header (above the hanging sub-inputs); the vault and + * recipient spread anchors across their full height so the hub's many spokes + * fan out instead of piling onto one point. + */ +const anchorBand = (node: IFlowGraphNode): { top: number; bottom: number } => + node.kind === 'strategy' + ? { top: node.y + 14, bottom: node.y + 58 } + : { top: node.y + 24, bottom: node.y + node.h - 24 }; + +/** Extra spacing (in slot units) inserted between one leg's anchor group and + * the next, so the hub's spokes read as per-strategy clusters, not one wall. */ +const CLUSTER_GAP = 1; + +/** Direction-aware cubic between two side anchors so the curve always exits a + * node away from its body and re-enters the target cleanly — works for the + * rightward feeds, the leftward `returns` loop, and outbound distributes. */ +const edgePath = (anchors: IEdgeAnchors): string => { + const { a, b, aSide, bSide } = anchors; + const dx = Math.max(48, Math.abs(b.x - a.x) * 0.5); + const c1x = a.x + (aSide === 'right' ? dx : -dx); + const c2x = b.x + (bSide === 'right' ? dx : -dx); + return `M ${a.x} ${a.y} C ${c1x} ${a.y}, ${c2x} ${b.y}, ${b.x} ${b.y}`; +}; + +/** Consumed (vault→leg) anchors sort above produced (leg→vault / →recipient). */ +const edgeGroup = (edge: IFlowGraphEdge): number => + edge.kind === 'feeds' ? 0 : 1; + +interface IEnd { + edgeId: string; + end: 'a' | 'b'; + side: Side; + group: number; + /** The node at the other end — anchors are clustered per partner. */ + partnerId: string; + partnerY: number; +} + +/** + * Resolve every edge's two endpoint anchors. Sides are geometric (an endpoint + * always faces its partner). Anchors sharing a (node, side) are grouped by the + * partner they connect to — so each leg's draw-from and return-to arrows sit + * together as one cluster, clusters order by the partner's vertical position, + * and a gap is left between clusters so the hub's spokes read per-strategy + * instead of as one dense wall. + */ +const resolveAnchors = ( + nodes: IFlowGraphNode[], + edges: IFlowGraphEdge[], +): Map => { + const byId = new Map(nodes.map((n) => [n.id, n])); + // (nodeId|side) → ends touching it. + const buckets = new Map(); + const push = ( + node: IFlowGraphNode, + side: Side, + e: Omit, + ): void => { + const key = `${node.id}|${side}`; + const list = buckets.get(key); + const entry = { ...e, side }; + if (list) { + list.push(entry); + } else { + buckets.set(key, [entry]); + } + }; + + for (const edge of edges) { + const from = byId.get(edge.source); + const to = byId.get(edge.target); + if (!(from && to)) { + continue; + } + // Each endpoint faces the other node. + const aSide: Side = centerX(to) >= centerX(from) ? 'right' : 'left'; + const bSide: Side = aSide === 'right' ? 'left' : 'right'; + const group = edgeGroup(edge); + push(from, aSide, { + edgeId: edge.id, + end: 'a', + group, + partnerId: to.id, + partnerY: centerY(to), + }); + push(to, bSide, { + edgeId: edge.id, + end: 'b', + group, + partnerId: from.id, + partnerY: centerY(from), + }); + } + + const result = new Map(); + const ensure = (id: string): IEdgeAnchors => { + const existing = result.get(id); + if (existing) { + return existing; + } + const fresh: IEdgeAnchors = { + a: { x: 0, y: 0 }, + b: { x: 0, y: 0 }, + aSide: 'right', + bSide: 'left', + }; + result.set(id, fresh); + return fresh; + }; + + for (const [key, ends] of buckets) { + const [nodeId, side] = key.split('|') as [string, Side]; + const node = byId.get(nodeId); + if (!node) { + continue; + } + // Cluster ends by partner; order clusters by the partner's height; keep + // draw-from (feeds) above return-to (returns) inside each cluster. + const clusterY = new Map(); + for (const e of ends) { + const cur = clusterY.get(e.partnerId); + if (cur == null || e.partnerY < cur) { + clusterY.set(e.partnerId, e.partnerY); + } + } + ends.sort((p, q) => { + const cp = clusterY.get(p.partnerId) ?? 0; + const cq = clusterY.get(q.partnerId) ?? 0; + if (cp !== cq) { + return cp - cq; + } + if (p.partnerId !== q.partnerId) { + return p.partnerId < q.partnerId ? -1 : 1; + } + return p.group - q.group; + }); + + // Slot positions with an extra gap whenever the cluster changes. + const slots: number[] = []; + let cursor = 0; + ends.forEach((e, i) => { + if (i > 0 && e.partnerId !== ends[i - 1]?.partnerId) { + cursor += CLUSTER_GAP; + } + slots.push(cursor); + cursor += 1; + }); + const total = cursor; // includes the trailing slot's width + const { top, bottom } = anchorBand(node); + const x = side === 'right' ? node.x + node.w : node.x; + ends.forEach((e, i) => { + const y = + total <= 1 + ? (top + bottom) / 2 + : top + (((slots[i] ?? 0) + 0.5) / total) * (bottom - top); + const anchors = ensure(e.edgeId); + anchors[e.end] = { x, y }; + if (e.end === 'a') { + anchors.aSide = side; + } else { + anchors.bSide = side; + } + }); + } + + return result; +}; + +const edgeColor = (edge: IFlowGraphEdge, replaying: boolean): string => { + if (edge.blocked) { + return 'var(--color-critical-400)'; + } + if (replaying) { + return 'var(--color-neutral-200)'; + } + // Settle-phase flows (e.g. the CoW buyback that lands when the order is + // settled, not at dispatch) read in a distinct warm hue — clearly NOT the + // blue "happens now" dispatch flow. + if (edge.trigger === 'settle') { + return 'var(--color-warning-500)'; + } + if (edge.fidelity === 'opaque') { + return 'var(--color-primary-200)'; + } + return 'var(--color-primary-300)'; +}; + +const dashFor = (fidelity: FlowFidelity): string | undefined => { + if (fidelity === 'estimated') { + return '7 6'; + } + if (fidelity === 'opaque') { + return '2 6'; + } + return undefined; +}; + +interface IView { + k: number; + x: number; + y: number; +} + +const MIN_K = 0.2; +const MAX_K = 2.5; +const clampK = (k: number): number => Math.min(MAX_K, Math.max(MIN_K, k)); + +/** + * Pan + zoom over the fixed-coordinate stage. The stage starts fitted & + * centred (the previous auto-fit behaviour); after that the user can scroll to + * zoom toward the cursor and drag the background to pan. Re-fits automatically + * only while the user hasn't interacted yet (so a flow/run switch still frames + * the graph), and on explicit reset. + */ +const usePanZoom = ( + ref: React.RefObject, + stageW: number, + stageH: number, +) => { + const [view, setView] = useState({ k: 1, x: 0, y: 0 }); + const interacted = useRef(false); + + const fit = useCallback((): IView | null => { + const el = ref.current; + if (!el || stageW === 0 || stageH === 0) { + return null; + } + const cw = el.clientWidth; + const ch = el.clientHeight; + if (!(cw && ch)) { + return null; + } + // Reserve room for the floating panels so auto-fit (on load / flow + // switch) never frames the node cards underneath the left control + // column or the bottom history bar — the canvas bleeds under them, the + // content doesn't land there. + const INSET_LEFT = 400; + const INSET_RIGHT = 32; + const INSET_TOP = 24; + const INSET_BOTTOM = 80; + const availW = Math.max(1, cw - INSET_LEFT - INSET_RIGHT); + const availH = Math.max(1, ch - INSET_TOP - INSET_BOTTOM); + const k = clampK(Math.min(availW / stageW, availH / stageH, 1)); + return { + k, + x: INSET_LEFT + (availW - stageW * k) / 2, + y: INSET_TOP + (availH - stageH * k) / 2, + }; + }, [ref, stageW, stageH]); + + const reset = useCallback(() => { + const next = fit(); + if (next) { + interacted.current = false; + setView(next); + } + }, [fit]); + + // (Re)fit until the user takes over, and keep the fit responsive to resizes. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-fit when the stage size changes + useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + const apply = () => { + if (interacted.current) { + return; + } + const next = fit(); + if (next) { + setView(next); + } + }; + apply(); + const raf = requestAnimationFrame(apply); + const ro = new ResizeObserver(apply); + ro.observe(el); + return () => { + ro.disconnect(); + cancelAnimationFrame(raf); + }; + }, [fit, stageW, stageH]); + + const zoomAt = useCallback((factor: number, px: number, py: number) => { + setView((v) => { + const k = clampK(v.k * factor); + if (k === v.k) { + return v; + } + // Keep the stage point under (px,py) fixed across the zoom. + const wx = (px - v.x) / v.k; + const wy = (py - v.y) / v.k; + return { k, x: px - wx * k, y: py - wy * k }; + }); + interacted.current = true; + }, []); + + return { view, setView, reset, zoomAt, interacted }; +}; + +/* ---------------------------------------------------------------- SubChip */ + +const SubChip: React.FC<{ sub: IFlowSubInput; muted: boolean }> = ({ + sub, + muted, +}) => { + const display = getSubInputDisplay(sub.role, sub.kind); + const closed = sub.role === 'gate' && sub.status === 'closed'; + const open = sub.role === 'gate' && sub.status === 'open'; + const showEpoch = sub.role === 'epoch' && sub.epoch != null; + const hasValues = sub.reading != null || showEpoch || sub.detail != null; + return ( + // A SUB-card of the strategy above it — intentionally smaller and more + // muted (compact rounded-lg, tinted state wash, small bordered icon) so + // it reads as a part of the bigger strategy card, not a peer. +
    +
    + + + + + {sub.label} + + {closed && Closed} + {open && Open} +
    + {sub.note && ( +
    + {sub.note} +
    + )} + {hasValues && ( +
    + {sub.reading != null && ( + + )} + {showEpoch && ( + + #{sub.epoch?.toLocaleString('en-US')} + {sub.epochLength ? ` · ${sub.epochLength}` : ''} + + )} + {sub.detail && ( +
    + {sub.detail} +
    + )} +
    + )} +
    + ); +}; + +/* ----------------------------------------------------------- StrategyModule */ + +const StrategyModule: React.FC<{ + node: IFlowGraphNode; + selected: boolean; + replaying: boolean; + onSelect: (id: string) => void; +}> = ({ node, selected, replaying, onSelect }) => { + const state = MM_STATES[node.state]; + const display = node.primitiveKind + ? getStrategyDisplay(node.primitiveKind) + : { icon: 'blockchain-smartcontract' }; + const pulse = + !(REDUCED || replaying) && + (node.state === 'accumulating' || + node.state === 'blocked' || + node.state === 'firing'); + + return ( +
    + + + {node.inputs.length > 0 && ( +
    + {node.inputs.map((sub) => { + const closed = + sub.role === 'gate' && sub.status === 'closed'; + return ( +
    + + +
    + ); + })} +
    + )} +
    + ); +}; + +/* -------------------------------------------------------------- NetToVault */ + +/** Per-token net delta landing back in the DAO after the dispatch — the value + * the old inferred-pipeline model used to hide. `+` = produced back to the + * vault, `−` = drawn out; opaque contributors render as a `~` estimate. */ +const NetToVault: React.FC<{ net?: IFlowNetEntry[] }> = ({ net }) => { + if (!net || net.length === 0) { + return null; + } + return ( +
    + + Net this dispatch + + {net.map((entry) => { + const positive = entry.delta >= 0; + const pending = entry.opaque && entry.delta === 0; + return ( +
    + + {entry.token} + + {pending ? ( + + On fill + + ) : ( + + {positive ? '+' : '−'} + {entry.opaque ? '~' : ''} + {Math.abs(entry.delta).toLocaleString('en-US', { + maximumFractionDigits: 4, + })} + + )} +
    + ); + })} +
    + ); +}; + +/* ------------------------------------------------------------- EndpointCard */ + +const EndpointCard: React.FC<{ + node: IFlowGraphNode; + selected: boolean; + onSelect: (id: string) => void; +}> = ({ node, selected, onSelect }) => { + const isSource = node.kind === 'source'; + return ( + + ); +}; + +/* --------------------------------------------------------------- EdgeLabel */ + +const EdgeLabel: React.FC<{ + edge: IFlowGraphEdge; + mid: IPoint; + replaying: boolean; +}> = ({ edge, mid, replaying }) => ( +
    + + {edge.perEpoch && ( + /epoch + )} + {edge.trigger === 'settle' && ( + + + + + on settle + + )} + {edge.blocked && ( + + + + + blocked + + )} +
    +); + +/* --------------------------------------------------------------- FlowCanvas */ + +export interface IFlowCanvasProps { + graph: IFlowGraph; + selected: string | null; + onSelect: (id: string | null) => void; + /** True when the graph represents a replayed historical run. */ + replaying: boolean; +} + +export const FlowCanvas: React.FC = (props) => { + const { graph, selected, onSelect, replaying } = props; + const wrapRef = useRef(null); + const { view, setView, reset, zoomAt, interacted } = usePanZoom( + wrapRef, + graph.width, + graph.height, + ); + // Drag-to-pan from the background (not from a node/button, so clicks select). + const pan = useRef<{ x: number; y: number } | null>(null); + const [panning, setPanning] = useState(false); + + const anchors = useMemo( + () => resolveAnchors(graph.nodes, graph.edges), + [graph.nodes, graph.edges], + ); + + const onWheel = (e: React.WheelEvent) => { + const el = wrapRef.current; + if (!el) { + return; + } + const rect = el.getBoundingClientRect(); + // Dampened, magnitude-aware zoom: trackpads fire many small-delta events + // and mouse wheels a few large ones — scaling by deltaY (clamped) keeps + // both smooth instead of a fixed 12% jump per tick. + const clamped = Math.max(-40, Math.min(40, e.deltaY)); + const factor = Math.exp(-clamped * 0.005); + zoomAt(factor, e.clientX - rect.left, e.clientY - rect.top); + }; + const onPointerDown = (e: React.PointerEvent) => { + // Only pan when the drag starts on empty canvas, never on a node. + if ((e.target as HTMLElement).closest('button')) { + return; + } + pan.current = { x: e.clientX, y: e.clientY }; + setPanning(true); + interacted.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + }; + const onPointerMove = (e: React.PointerEvent) => { + if (!pan.current) { + return; + } + const dx = e.clientX - pan.current.x; + const dy = e.clientY - pan.current.y; + pan.current = { x: e.clientX, y: e.clientY }; + setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy })); + }; + const endPan = () => { + pan.current = null; + setPanning(false); + }; + + return ( +
    +
    +
    + + + + {graph.edges.map((e) => { + const anchor = anchors.get(e.id); + if (!anchor) { + return null; + } + const { a, b } = anchor; + const mid = { + x: (a.x + b.x) / 2, + y: + (a.y + b.y) / 2 - + (Math.abs(a.y - b.y) < 12 ? 22 : 0), + }; + return ( + + ); + })} + + {graph.nodes.map((n) => { + if (n.kind === 'strategy') { + return ( + + ); + } + return ( + + ); + })} +
    + + zoomCentre(wrapRef, zoomAt, 1.2)} + onZoomOut={() => zoomCentre(wrapRef, zoomAt, 1 / 1.2)} + scale={view.k} + /> +
    + ); +}; + +/** Zoom around the container centre — for the +/- buttons. */ +const zoomCentre = ( + ref: React.RefObject, + zoomAt: (factor: number, px: number, py: number) => void, + factor: number, +): void => { + const el = ref.current; + if (!el) { + return; + } + zoomAt(factor, el.clientWidth / 2, el.clientHeight / 2); +}; + +const ZoomControls: React.FC<{ + scale: number; + onZoomIn: () => void; + onZoomOut: () => void; + onFit: () => void; +}> = ({ scale, onZoomIn, onZoomOut, onFit }) => ( +
    + + + +
    +); diff --git a/src/modules/flow/components/flowWorkbench/flowFocus.tsx b/src/modules/flow/components/flowWorkbench/flowFocus.tsx new file mode 100644 index 0000000000..98a2b0dfdc --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/flowFocus.tsx @@ -0,0 +1,255 @@ +'use client'; + +/** + * Focus layout — the single canvas display for the Money Machine flow. The + * capital-flow graph goes full-bleed and every control floats over it as a + * glass panel: flow selector + live badge (top-left), next-run + dispatch + * controls + demo actions (top-center), this-flow stats + inspector (right + * column), and a collapsible dispatch history (bottom-left). + * + * The canvas shows the UPCOMING dispatch (what the next dispatch will move), + * not the last settled run — replay a past run from the history strip. + */ + +import { Card } from '@aragon/gov-ui-kit'; +import { useCallback, useEffect, useState } from 'react'; +import type { IFlowGraph } from '../../canvas/flowGraphTypes'; +import { LMM_RPC_URL } from '../../demo/lmmDemoConfig'; +import { assertForkRpc } from '../../demo/safety'; +import { useLmmActionContext } from '../../demo/useLmmActionContext'; +import { dispatchAction } from '../lidoMoneyMachine/actions'; +import './workbench.css'; +import type { IFlowDescription } from './buildWorkbenchModel'; +import { FlowCanvas } from './flowCanvas'; +import { FlowDescriptionCard, TokenFlowList } from './flowInfoPanels'; +import { MmIcon } from './mmIcon'; +import { LiveBadge } from './mmPrimitives'; +import { WorkbenchDemoActions } from './workbenchDemoActions'; +import { + AutoToggle, + CumulativeStats, + DispatchControls, + FlowSelector, + NextRunSummary, +} from './workbenchHeader'; +import type { IHistoryRun, IWorkbenchModel } from './workbenchModel'; +import { + HistoryStrip, + Inspector, + ReplayBanner, + SimulateModal, +} from './workbenchPanels'; + +const GLASS = + 'rounded-2xl border border-neutral-100 bg-neutral-0/85 shadow-neutral-md backdrop-blur-md'; + +/** Shared eyebrow heading — one uniform style across every panel. */ +const EYEBROW = + 'font-semibold text-neutral-500 text-xs uppercase tracking-[0.06em]'; + +export interface IFlowFocusProps { + model: IWorkbenchModel; + /** Graph for the current view (upcoming dispatch or the active replayed run). */ + graph: IFlowGraph | null; + /** Human-language description of the flow (rendered as a canvas overlay). */ + description?: IFlowDescription; + recipient?: { label: string; address?: string }; + /** Active replayed run (null = live upcoming-dispatch view). */ + activeRun: string | null; + onReplay: (run: string) => void; + onClearReplay: () => void; + onSelectFlow: (id: string) => void; + onDispatch: () => void; + canDispatch: boolean; + /** Why dispatch is disabled (tooltip on the disabled button). */ + dispatchReason?: string; +} + +export const FlowFocus: React.FC = (props) => { + const { + model, + graph, + description, + recipient, + activeRun, + onReplay, + onClearReplay, + onSelectFlow, + onDispatch, + canDispatch, + dispatchReason, + } = props; + + const [selected, setSelected] = useState(null); + const [simOpen, setSimOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + + // In demo mode, dispatch the fork tx directly from this modal — no second + // confirm dialog. Outside demo mode, fall back to the provider's dispatch + // flow (wallet/review wizard). + const demoCtx = useLmmActionContext(); + const handleDispatch = useCallback(() => { + if (!demoCtx) { + onDispatch(); + return; + } + (async () => { + try { + assertForkRpc(); + await dispatchAction(demoCtx); + } catch (e) { + // biome-ignore lint/suspicious/noConsole: demo presenter affordance + console.error('[lmm-demo] dispatch failed:', e); + } + })(); + }, [demoCtx, onDispatch]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset is keyed on the view identity + useEffect(() => { + setSelected(null); + }, [model.selectedFlowId, activeRun]); + + const selectedNode = graph?.nodes.find((n) => n.id === selected) ?? null; + const runForBanner: IHistoryRun | null = + activeRun != null + ? (model.history.find((h) => h.run === activeRun) ?? null) + : null; + + return ( +
    + {/* full-bleed canvas */} +
    + {graph ? ( + + ) : ( +
    +
    + Select a flow +
    +
    + Pick a flow to see its capital-flow graph. +
    +
    + )} +
    + + {/* left column: the single control center — flow selector, the + dispatch bar (moved off the canvas centre to give the working + area air), the flow description, then cumulative stats. */} +
    +
    + + {model.isLive && } +
    +
    +
    + {model.nextRun ? ( + + ) : ( + + )} + +
    + setSimOpen(true)} + /> + +
    + {description && ( + + )} + {model.stats && ( + +
    Cumulative
    + + {model.stats.tokenFlows.length > 0 && ( +
    +
    + Token throughput +
    + +
    + )} +
    + )} +
    + + {/* right column: inspector for the selected node only */} +
    + {selectedNode && ( + setSelected(null)} + recipient={recipient} + /> + )} +
    + + {/* bottom-left: replay bar rides above the collapsible history — + the whole stack grows upward from the bottom, so the bar tracks + the history as it expands/collapses. */} +
    + {runForBanner && ( + + )} + {historyOpen ? ( +
    + setHistoryOpen(false)} + onSelect={(run) => { + if (run === activeRun) { + onClearReplay(); + } else { + onReplay(run); + setHistoryOpen(false); + } + }} + /> +
    + ) : ( + + )} +
    + + {simOpen && model.nextRun && ( + setSimOpen(false)} + onDispatch={handleDispatch} + /> + )} +
    + ); +}; diff --git a/src/modules/flow/components/flowWorkbench/flowInfoPanels.tsx b/src/modules/flow/components/flowWorkbench/flowInfoPanels.tsx new file mode 100644 index 0000000000..28e02ee460 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/flowInfoPanels.tsx @@ -0,0 +1,84 @@ +'use client'; + +/** + * Small shared canvas panels reused by both the Workbench and Focus layouts: + * a human-language flow description ("what this flow does") and the full + * per-token throughput breakdown (how much of each token went out / came back). + */ + +import { Card } from '@aragon/gov-ui-kit'; +import type { IFlowDescription } from './buildWorkbenchModel'; +import { fmtAmount } from './mmPrimitives'; +import type { ITokenFlow } from './workbenchModel'; + +export interface IFlowDescriptionCardProps { + description: IFlowDescription; + className?: string; +} + +export const FlowDescriptionCard: React.FC = ({ + description, + className, +}) => ( + +
    + What this flow does +
    +
    + {description.title} +
    +

    + {description.summary} +

    +
    +); + +export interface ITokenFlowListProps { + flows: ITokenFlow[]; +} + +/** Per-token throughput: ↑ left the vault, ↓ returned to the vault. */ +export const TokenFlowList: React.FC = ({ flows }) => { + if (flows.length === 0) { + return ( +
    No token flow yet.
    + ); + } + return ( +
    +
    + Token + + ↑ Out + ↓ Back + +
    + {flows.map((f) => { + const inLabel = + f.opaqueIn && f.inAmount === 0 + ? 'LP' + : (fmtAmount(f.inAmount) ?? '—'); + return ( +
    + + {f.token} + + + + {f.outAmount > 0 + ? (fmtAmount(f.outAmount) ?? '—') + : '—'} + + + {f.inAmount > 0 || f.opaqueIn ? inLabel : '—'} + + +
    + ); + })} +
    + ); +}; diff --git a/src/modules/flow/components/flowWorkbench/index.ts b/src/modules/flow/components/flowWorkbench/index.ts new file mode 100644 index 0000000000..ccf7d4edf5 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/index.ts @@ -0,0 +1,2 @@ +export type { IFlowFocusProps } from './flowFocus'; +export { FlowFocus } from './flowFocus'; diff --git a/src/modules/flow/components/flowWorkbench/mmIcon.tsx b/src/modules/flow/components/flowWorkbench/mmIcon.tsx new file mode 100644 index 0000000000..0cc2e1ce0e --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/mmIcon.tsx @@ -0,0 +1,116 @@ +import { type IconType as GukIconType, Icon } from '@aragon/gov-ui-kit'; +import type { CSSProperties } from 'react'; + +/** + * Workbench icon adapter. + * + * Standard glyphs render through gov-ui-kit's `` (the real kit icons — we + * do NOT vendor kit SVGs). Only the five glyphs the kit genuinely lacks + * (gate, swap, droplet, bolt, sparkle — authored for this workbench) ship as + * inline SVG. Sizing is controlled by a wrapper span (`.mm-ic`) so any px size + * from the design maps exactly; `.mm-ic svg { width/height: 100% }` lives in + * the workbench stylesheet. + * + * `name` is the registry's icon token (kebab-case), matching the design's + * naming so the ported components read unchanged. + */ + +/** kebab icon token → gov-ui-kit IconType key. */ +const GUK: Record = { + 'app-dashboard': 'APP_DASHBOARD', + 'app-transactions': 'APP_TRANSACTIONS', + 'app-gauge': 'APP_GAUGE', + 'blockchain-wallet': 'BLOCKCHAIN_WALLET', + 'blockchain-block': 'BLOCKCHAIN_BLOCK', + 'blockchain-smartcontract': 'BLOCKCHAIN_SMARTCONTRACT', + 'burn-assets': 'BURN_ASSETS', + reload: 'RELOAD', + withdraw: 'WITHDRAW', + deposit: 'DEPOSIT', + clock: 'CLOCK', + 'chevron-down': 'CHEVRON_DOWN', + 'chevron-right': 'CHEVRON_RIGHT', + 'chevron-up': 'CHEVRON_UP', + close: 'CLOSE', + copy: 'COPY', + 'link-external': 'LINK_EXTERNAL', + settings: 'SETTINGS', + menu: 'MENU', + success: 'SUCCESS', + critical: 'CRITICAL', + warning: 'WARNING', + info: 'INFO', + checkmark: 'CHECKMARK', + 'dots-vertical': 'DOTS_VERTICAL', + expand: 'EXPAND', + shrink: 'SHRINK', + filter: 'FILTER', + search: 'SEARCH', + plus: 'PLUS', + minus: 'MINUS', +}; + +/** Custom glyphs the kit lacks — authored for the money-machine canvas. */ +const CUSTOM: Record = { + gate: '', + swap: '', + droplet: + '', + bolt: '', + sparkle: + '', +}; + +export interface IMmIconProps { + name: string; + /** Edge length in px. */ + size?: number; + className?: string; + style?: CSSProperties; + title?: string; +} + +export const MmIcon: React.FC = (props) => { + const { name, size = 16, className, style, title } = props; + const wrapperStyle: CSSProperties = { + width: size, + height: size, + display: 'inline-flex', + flex: '0 0 auto', + lineHeight: 0, + ...style, + }; + + const custom = CUSTOM[name]; + if (custom) { + return ( + + ); + } + + const gukKey = GUK[name]; + return ( + + + + ); +}; diff --git a/src/modules/flow/components/flowWorkbench/mmPrimitives.tsx b/src/modules/flow/components/flowWorkbench/mmPrimitives.tsx new file mode 100644 index 0000000000..974ab1bb2c --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/mmPrimitives.tsx @@ -0,0 +1,204 @@ +'use client'; + +/** + * Small workbench primitives that gov-ui-kit doesn't provide directly: + * a token-amount renderer, a stat block, a micro status pill (the kit `Tag` + * is intentionally larger), an iOS-style on/off switch, a live badge and a + * click-away backdrop. Everything else (buttons, cards, full-size tags, empty + * states, spinners, icons) comes straight from the kit. + * + * Styled with Tailwind on kit color tokens — no vendored CSS. + */ + +import classNames from 'classnames'; +import type { CSSProperties, ReactNode } from 'react'; +import type { FlowFidelity } from '../../canvas/flowGraphTypes'; +import { type MmTone, toneChip } from './tone'; + +/** + * Format a numeric token amount with thousands separators (no forced decimals + * unless fractional). Returns `null` for nullish input so callers can branch on + * "opaque/unknown" amounts. + */ +export const fmtAmount = (n: number | null | undefined): string | null => { + if (n == null) { + return null; + } + const hasFrac = Math.abs(n % 1) > 1e-9; + return n.toLocaleString('en-US', { + minimumFractionDigits: hasFrac ? 1 : 0, + maximumFractionDigits: 2, + }); +}; + +export interface IPillProps { + tone?: MmTone; + children: ReactNode; + /** Render a leading status dot tinted to the tone. */ + dot?: boolean; + className?: string; +} + +/** Micro status pill — smaller than the kit `Tag`, for canvas/inspector tags. */ +export const Pill: React.FC = (props) => { + const { tone = 'neutral', children, dot, className } = props; + return ( + + {dot && ( + + )} + {children} + + ); +}; + +export interface ISwitchProps { + on: boolean; + onChange?: (next: boolean) => void; + label: string; + disabled?: boolean; +} + +/** iOS-style on/off switch (kit ships only a segmented Toggle). */ +export const Switch: React.FC = (props) => { + const { on, onChange, label, disabled } = props; + return ( + + ); +}; + +export const LiveBadge: React.FC<{ label?: string }> = ({ label = 'Live' }) => ( + + + {label} + +); + +export interface IAmountProps { + amount: number | null; + token?: string; + fidelity?: FlowFidelity; + showToken?: boolean; + big?: boolean; + className?: string; +} + +export const Amount: React.FC = (props) => { + const { + amount, + token, + fidelity = 'real', + showToken = true, + big, + className, + } = props; + const valSize = big ? 'text-xl' : ''; + const opaque = fidelity === 'opaque' || amount == null; + + if (opaque) { + // Settled-on-fill amounts (e.g. CowSwap) — only knowable post-execution. + // Yellow (warning) marks them apart from estimated (blue) and real + // (black); no "pending" word — the "on fill" tag carries the meaning. + const val = /LP/.test(token ?? '') ? 'LP' : (token ?? '?'); + return ( + + + {val} + + + on fill + + + ); + } + + const prefix = fidelity === 'estimated' ? '~' : ''; + return ( + + + {prefix} + {fmtAmount(amount)} + + {showToken && token && ( + + {token} + + )} + + ); +}; + +export interface IStatProps { + label: string; + children: ReactNode; + accent?: boolean; + style?: CSSProperties; +} + +export const Stat: React.FC = ({ label, children, accent }) => ( +
    +
    {label}
    +
    + {children} +
    +
    +); diff --git a/src/modules/flow/components/flowWorkbench/tone.ts b/src/modules/flow/components/flowWorkbench/tone.ts new file mode 100644 index 0000000000..169fb6770b --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/tone.ts @@ -0,0 +1,48 @@ +/** + * Tone → Tailwind class maps for the workbench, on gov-ui-kit color tokens. + * + * The Claude Design bundle expressed tones via three CSS vars per scheme + * (`--tone` / `--tone-bg` / `--tone-strong`); here we resolve them to static + * Tailwind utility strings so the kit's token scales drive the colour and + * nothing is vendored. Keyed by {@link MmTone}, which also lines up 1:1 with + * gov-ui-kit's `TagVariant`. + */ + +import type { TagVariant } from '@aragon/gov-ui-kit'; +import type { FlowRuntimeState } from '../../canvas/flowGraphTypes'; + +export type MmTone = TagVariant; // neutral | info | warning | critical | success | primary + +/** Node-state vocabulary → label + tone. Mirrors the design's `STATES`. */ +export const MM_STATES: Record< + FlowRuntimeState, + { label: string; tone: MmTone } +> = { + idle: { label: 'Idle', tone: 'neutral' }, + accumulating: { label: 'Accumulating', tone: 'info' }, + firing: { label: 'Firing', tone: 'primary' }, + blocked: { label: 'Blocked', tone: 'critical' }, + done: { label: 'Done', tone: 'success' }, + failed: { label: 'Failed', tone: 'critical' }, + skipped: { label: 'Skipped', tone: 'neutral' }, +}; + +/** Soft chip / badge surface — background + strong text. */ +export const toneChip: Record = { + neutral: 'bg-neutral-100 text-neutral-700', + info: 'bg-info-100 text-info-800', + primary: 'bg-primary-50 text-primary-700', + critical: 'bg-critical-100 text-critical-800', + success: 'bg-success-100 text-success-800', + warning: 'bg-warning-100 text-warning-800', +}; + +/** Raw accent colour utility for pulse rings / dots. */ +export const tonePulseVar: Record = { + neutral: 'var(--color-neutral-400)', + info: 'var(--color-info-500)', + primary: 'var(--color-primary-500)', + critical: 'var(--color-critical-500)', + success: 'var(--color-success-600)', + warning: 'var(--color-warning-500)', +}; diff --git a/src/modules/flow/components/flowWorkbench/workbench.css b/src/modules/flow/components/flowWorkbench/workbench.css new file mode 100644 index 0000000000..fce628ac53 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/workbench.css @@ -0,0 +1,83 @@ +/* ============================================================================ + Flow Workbench — minimal canvas-only stylesheet. + + The workbench chrome (header, rail, inspector, history, buttons, cards, tags) + is built with gov-ui-kit components + Tailwind utilities on kit tokens — NOT + here. This file carries only what Tailwind/kit can't express cleanly: + the dotted grid backdrop, two keyframe animations, and the icon-sizing rule + for `` (which sizes a gov-ui-kit `` via a px wrapper span). + ============================================================================ */ + +/* MmIcon: let a px-sized wrapper span drive the kit 's svg dimensions. */ +.mm-ic svg { + display: block; + width: 100%; + height: 100%; +} + +/* Dotted-grid canvas backdrop. */ +.mm-grid { + position: absolute; + inset: -200px; + background-image: radial-gradient( + circle, + var(--color-neutral-200, #cbd2d9) 1.2px, + transparent 1.2px + ); + background-size: 26px 26px; + opacity: 0.5; +} + +/* Travelling-dot glow on active edges. */ +.mm-flow-dot { + filter: drop-shadow(0 0 5px rgba(0, 59, 245, 0.55)); +} + +/* Selected-node accumulating/firing/blocked pulse — uses the node's + `--mm-pulse` colour set inline per tone. */ +.mm-pulse::after { + content: ""; + position: absolute; + inset: -3px; + border-radius: inherit; + border: 2px solid var(--mm-pulse, currentColor); + opacity: 0; + pointer-events: none; + animation: mm-node-pulse 2.1s ease-out infinite; +} +@keyframes mm-node-pulse { + 0% { + opacity: 0.5; + transform: scale(1); + } + 70% { + opacity: 0; + transform: scale(1.045); + } + 100% { + opacity: 0; + } +} + +/* Live-badge blink. */ +.mm-blink { + animation: mm-blink 1.8s ease-out infinite; +} +@keyframes mm-blink { + 0% { + box-shadow: 0 0 0 0 rgba(116, 175, 7, 0.5); + } + 70% { + box-shadow: 0 0 0 7px rgba(116, 175, 7, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(116, 175, 7, 0); + } +} + +@media (prefers-reduced-motion: reduce) { + .mm-pulse::after, + .mm-blink { + animation: none; + } +} diff --git a/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx b/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx new file mode 100644 index 0000000000..d60e965349 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx @@ -0,0 +1,246 @@ +'use client'; + +/** + * Demo presenter controls for the workbench — the interactive "cheats" that let + * stakeholders click through the flow against the Anvil fork: open/close the + * price-floor gate, skip an epoch, warp time, top up balances, settle the CoW + * order, dispatch. Reuses the vendored `lidoMoneyMachine/actions.ts` (same + * behaviour as the CLI demo) so nothing is reimplemented; only the chrome is + * kit-styled to match the workbench. + * + * Renders nothing outside `LMM_DEMO_MODE` or before the manifest loads — it is + * strictly a demo affordance, never part of the production render path. + */ + +import { Button, Dropdown, IconType } from '@aragon/gov-ui-kit'; +import { useState } from 'react'; +import { parseEther } from 'viem'; +import { LMM_DEMO_MODE } from '../../demo/lmmDemoConfig'; +import { assertForkRpc } from '../../demo/safety'; +import { useLmmActionContext } from '../../demo/useLmmActionContext'; +import { + type ActionContext, + dispatchAction, + refreshOracle, + setEthPrice, + setTargetEpoch, + settleCowSwapAuto, + topUpLdo, + topUpStEth, + warpAction, +} from '../lidoMoneyMachine/actions'; +import { MmIcon } from './mmIcon'; + +// ETH/USD prices either side of the PriceFloorGate's $3,000 floor. +const GATE_OPEN_USD = 3200; +const GATE_CLOSED_USD = 2800; + +// Demo epoch cadence: 1h per epoch (EpochProvider.epochLength at deploy). One +// "advance epoch" warps the fork clock by exactly one epoch so the stream's +// per-epoch slot re-arms and a fresh slice can be dispatched. +const EPOCH_SECONDS = 60 * 60; +// How far ahead the Fast Track action pushes the stream's targetEpoch. Must sit +// comfortably above the StreamUntilBudget floor so the stream leaves its floored +// regime (`balance / floorEpochs`) and meters `balance / (target − current)`. +const STREAM_HORIZON_EPOCHS = 24; + +interface IDemoAction { + key: string; + label: string; + description: string; + icon: string; + run: (ctx: ActionContext) => Promise; +} + +// Ordered as a presenter walks the demo: fund the vault → set the gate → +// dispatch → settle the buyback → advance time. Mirrors infra/lmm-demo/DEMO-SCRIPT.md. +const ACTION_GROUPS: { heading: string; actions: IDemoAction[] }[] = [ + { + heading: '1 · Fund the vault', + actions: [ + { + key: 'top-up-steth', + label: 'Top up 100 stETH', + description: 'Impersonate the stETH whale → DAO vault.', + icon: 'deposit', + run: (ctx) => topUpStEth(ctx, parseEther('100')), + }, + { + key: 'top-up-ldo', + label: 'Top up 1000 LDO', + description: 'Impersonate the Lido Agent → DAO vault.', + icon: 'deposit', + run: (ctx) => topUpLdo(ctx, parseEther('1000')), + }, + ], + }, + { + heading: '2 · Price-floor gate', + actions: [ + { + key: 'gate-open', + label: `Open gate · ETH $${GATE_OPEN_USD.toLocaleString('en-US')}`, + description: 'Set the oracle price above the $3,000 floor.', + icon: 'gate', + run: (ctx) => setEthPrice(ctx, GATE_OPEN_USD), + }, + { + key: 'gate-close', + label: `Close gate · ETH $${GATE_CLOSED_USD.toLocaleString('en-US')}`, + description: 'Set the oracle price below the $3,000 floor.', + icon: 'gate', + run: (ctx) => setEthPrice(ctx, GATE_CLOSED_USD), + }, + ], + }, + { + heading: '3 · Dispatch & settle', + actions: [ + { + key: 'dispatch', + label: 'Dispatch now', + description: 'Call dispatcher.dispatch() against the fork.', + icon: 'bolt', + run: (ctx) => dispatchAction(ctx), + }, + { + key: 'settle-cowswap', + label: 'Settle CoW order', + description: 'Fill the pending pre-signed buyback order.', + icon: 'swap', + run: (ctx) => settleCowSwapAuto(ctx), + }, + ], + }, + { + heading: '4 · Advance time', + actions: [ + { + key: 'advance-epoch', + label: 'Advance one epoch', + description: + 'Warp the fork clock one epoch (1h) + refresh oracle — re-arms the per-epoch stream slot.', + icon: 'clock', + run: async (ctx) => { + await warpAction(ctx, EPOCH_SECONDS); + await refreshOracle(ctx); + }, + }, + { + key: 'warp-1d', + label: 'Warp +1 day', + description: 'Advance the fork clock 24h + refresh oracle.', + icon: 'reload', + run: async (ctx) => { + await warpAction(ctx, 24 * 60 * 60); + await refreshOracle(ctx); + }, + }, + { + key: 'warp-7d', + label: 'Warp +7 days', + description: 'Advance the fork clock 7d + refresh oracle.', + icon: 'reload', + run: async (ctx) => { + await warpAction(ctx, 7 * 24 * 60 * 60); + await refreshOracle(ctx); + }, + }, + ], + }, + { + heading: '5 · Governance (Fast Track)', + actions: [ + { + key: 'extend-stream-horizon', + label: `Extend stream horizon · +${STREAM_HORIZON_EPOCHS} epochs`, + description: + 'Push the stream targetEpoch into the future via dao.execute() signed by the Lido Agent — models a Lido Fast Track proposal so the stream meters again.', + icon: 'settings', + run: (ctx) => setTargetEpoch(ctx, STREAM_HORIZON_EPOCHS), + }, + ], + }, +]; + +export const WorkbenchDemoActions: React.FC = () => { + const ctx = useLmmActionContext(); + const [open, setOpen] = useState(false); + const [busyKey, setBusyKey] = useState(null); + + if (!(LMM_DEMO_MODE && ctx)) { + return null; + } + const actionCtx = ctx; + + const run = (action: IDemoAction) => async () => { + setBusyKey(action.key); + try { + assertForkRpc(); + await action.run(actionCtx); + } catch (e) { + // Presenter tool — surface failures to devtools only; the live + // snapshot poll reflects the resulting state (or lack of change). + // biome-ignore lint/suspicious/noConsole: demo presenter affordance + console.error(`[lmm-demo] action ${action.key} failed:`, e); + } finally { + setBusyKey(null); + } + }; + + return ( + + Demo + + } + onOpenChange={setOpen} + open={open} + > + {/* Fixed width so the long action descriptions wrap inside the menu + instead of stretching it across the canvas. */} +
    +
    + Demo controls · Anvil fork +
    + {ACTION_GROUPS.map((group) => ( +
    +
    + {group.heading} +
    + {group.actions.map((action) => ( + + + + + + + + {action.label} + {busyKey === action.key && ' …'} + + + {action.description} + + + + + ))} +
    + ))} +
    +
    + ); +}; diff --git a/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx b/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx new file mode 100644 index 0000000000..5b725dfcea --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx @@ -0,0 +1,215 @@ +'use client'; + +/** + * Shared flow-canvas chrome pieces — flow selector, cumulative (token-only) + * stats, next-run summary and dispatch controls — consumed by the Focus layout. + * Auto-dispatch is rendered disabled (v1: the keeper service is a later + * feature); "Dispatch now" is wired to the real `plugin.dispatch()` via the + * provider. No USD anywhere — amounts are tokens. + */ + +import { Button, Dropdown, IconType } from '@aragon/gov-ui-kit'; +import { MmIcon } from './mmIcon'; +import { Switch } from './mmPrimitives'; +import type { + INextRun, + IWorkbenchModel, + IWorkbenchStats, +} from './workbenchModel'; + +interface IFlowSelectorProps { + dao: string; + flows: IWorkbenchModel['flows']; + value: string | null; + onChange: (id: string) => void; +} + +export const FlowSelector: React.FC = (props) => { + const { dao, flows, value, onChange } = props; + const current = flows.find((f) => f.id === value) ?? flows[0]; + + return ( + + + {current?.name ?? 'Select a flow'} + + + } + > +
    +
    + Flows · {dao} +
    + {flows.map((f) => ( + onChange(f.id)} + selected={f.id === value} + > + {/* Dropdown.Item renders its children inside a

    , so + every node here must be phrasing content — spans only, + no

    or kit (which renders its own

    ). */} + + + + {f.name} + + + {f.type} · {f.strategies}{' '} + {f.strategies === 1 + ? 'strategy' + : 'strategies'} + + + + {f.status} + + + + ))} +

    + + ); +}; + +export const CumulativeStats: React.FC<{ stats: IWorkbenchStats }> = ({ + stats, +}) => { + // Three equal-weight KPIs (oracle-net pattern): dispatches, success rate + // and how long the flow has been active. Per-token "moved / back" lives in + // the TOKEN THROUGHPUT table below (out = moved, back = buybacks), which + // scales to any token set — so we don't cram multi-token blobs here. + const items: { label: string; value: string; accent?: boolean }[] = [ + { + label: 'Dispatches', + value: `${stats.dispatches}`, + }, + { + label: 'Success rate', + value: `${Math.round(stats.successRate * 100)}%`, + accent: true, + }, + { + label: 'Active since', + value: `${stats.activeSinceDays}d`, + }, + ]; + return ( +
    + {items.map((it) => ( +
    + + {it.label} + + + {it.value} + +
    + ))} +
    + ); +}; + +export const NextRunSummary: React.FC<{ nextRun: INextRun }> = ({ + nextRun, +}) => ( +
    +
    + + {nextRun.readyIn ? ( + + Next run in{' '} + + {nextRun.readyIn} + + + ) : ( + Next run + )} + {nextRun.epoch != null && ( + <> + + + epoch{' '} + + {nextRun.epoch.toLocaleString('en-US')} + + + + )} +
    +
    +); + +/** + * Borderless "Auto" keeper toggle — sits next to the next-run / epoch line. + * Disabled for now; hover explains the keeper service isn't available yet. + */ +export const AutoToggle: React.FC = () => ( +
    + Auto + +
    +); + +interface IDispatchControlsProps { + /** Opens the simulate-&-dispatch modal (where the dispatch is confirmed). */ + onSimulate: () => void; + /** False when the next dispatch would be a no-op (empty balances, same + * epoch, gate closed) — disables the button so you can't fire nothing. */ + canDispatch?: boolean; + /** Tooltip explaining why dispatch is disabled. */ + dispatchReason?: string; +} + +export const DispatchControls: React.FC = (props) => { + const { onSimulate, canDispatch = true, dispatchReason } = props; + // Simulate first (shows the plan + net outcome), then confirm the dispatch + // from inside that modal. Disabled (reason on hover) when nothing would + // move — a disabled + + ); +}; diff --git a/src/modules/flow/components/flowWorkbench/workbenchModel.ts b/src/modules/flow/components/flowWorkbench/workbenchModel.ts new file mode 100644 index 0000000000..0d72ca5103 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/workbenchModel.ts @@ -0,0 +1,115 @@ +/** + * Presentational view-model for the Flow Workbench. + * + * These are the token-only, USD-free shapes the workbench chrome (header + * selector, cumulative stats, next-run summary, dispatch history) renders. + * They are assembled by `buildWorkbenchModel` from the generic flow data + * (`IFlowDaoData` + the live snapshot) — never hard-coded, never LMM-specific. + * + * The canvas itself is NOT described here: it is built separately from the + * `IFlowMachineDescriptor` + `IFlowDynamics` so live and replayed runs can be + * re-graphed reactively (see `buildFlowGraph`). + */ + +import type { FlowFidelity, IFlowNetEntry } from '../../canvas/flowGraphTypes'; + +/** A token-denominated amount (no USD, per v1). `amount === null` = opaque. */ +export interface ITokenAmount { + token: string; + amount: number | null; +} + +/** One entry in the flow dropdown. */ +export interface IFlowOption { + id: string; + name: string; + /** Generic type label, e.g. "Multi-dispatch" / "Router" / "Claimer". */ + type: string; + strategies: number; + /** Display status — "live" tones green, anything else neutral. */ + status: string; +} + +/** + * Per-token throughput across every run of a flow. `out` is everything that + * left the vault into a strategy/recipient (vault-out + external edges); `in` + * is everything produced back to the vault (vault-in edges). Lets the cumulative + * panel answer "how much stETH did we spend, how much LDO did we buy back". + */ +export interface ITokenFlow { + token: string; + inAmount: number; + outAmount: number; + /** Some inflow amount is opaque (e.g. LP minted pre-settlement). */ + opaqueIn?: boolean; +} + +/** Cumulative, token-only stats for the selected flow. */ +export interface IWorkbenchStats { + dispatches: number; + activeSinceDays: number; + /** 0..1. */ + successRate: number; + totalMoved: ITokenAmount[]; + buybacks: ITokenAmount[]; + /** Full per-token in/out breakdown across all runs. */ + tokenFlows: ITokenFlow[]; +} + +/** One leg of the simulated next dispatch. */ +export interface ISimStep { + kind: string; + willFire: boolean; + ins: ITokenAmountFidelity[]; + /** Every token the leg produces back to the vault / out — one per arrow. */ + outs: ITokenAmountFidelity[]; + skipReason?: string; +} + +export interface ITokenAmountFidelity extends ITokenAmount { + fidelity?: FlowFidelity; +} + +/** Predicted next dispatch summary (from the simulator). */ +export interface INextRun { + readyIn?: string; + epoch?: number; + summary: string; + steps: ISimStep[]; + /** Net token delta to the DAO vault after this dispatch (returns − draws, + * external outputs excluded). Mirrors the canvas vault node. */ + net: IFlowNetEntry[]; +} + +export type RunStatus = 'ok' | 'partial' | 'failed'; + +export interface IHistoryLeg { + kind: string; + ok?: boolean; + skipped?: boolean; + failed?: boolean; + detail?: string; + reason?: string; +} + +export interface IHistoryRun { + /** Stable run id (used in `?run=` selection + replay). */ + run: string; + /** Short numeric/label shown in the strip (e.g. "#42"). */ + label: string; + at: string; + status: RunStatus; + legs?: IHistoryLeg[]; + tx: string; +} + +export interface IWorkbenchModel { + dao: string; + flows: IFlowOption[]; + selectedFlowId: string | null; + stats: IWorkbenchStats | null; + nextRun: INextRun | null; + history: IHistoryRun[]; + /** Whether the selected flow has a live snapshot (drives the Live badge). */ + isLive: boolean; +} diff --git a/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx b/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx new file mode 100644 index 0000000000..53ba841c14 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx @@ -0,0 +1,753 @@ +'use client'; + +/** + * Workbench side/bottom panels: Inspector (selected-node live state), Legend + * (node states + data fidelity), HistoryStrip (one row per dispatch run with + * replay), ReplayBanner and the Simulate modal. All token-only, all driven by + * the generic graph + view-model — nothing LMM-specific. + */ + +import { Card, Dialog, Icon, IconType } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import type { FlowFidelity, IFlowGraphNode } from '../../canvas/flowGraphTypes'; +import { + getRecipientDisplay, + getSourceDisplay, + getStrategyDisplay, + getSubInputDisplay, +} from '../../canvas/primitiveRegistry'; +import { MmIcon } from './mmIcon'; +import { Amount, Pill } from './mmPrimitives'; +import { MM_STATES, type MmTone, toneChip } from './tone'; +import type { + IHistoryLeg, + IHistoryRun, + INextRun, + RunStatus, +} from './workbenchModel'; + +const truncateAddress = (address?: string): string => + address && address.length > 12 + ? `${address.slice(0, 6)}…${address.slice(-4)}` + : (address ?? ''); + +/* ---------------------------------------------------------------- Inspector */ + +export interface IInspectorProps { + node: IFlowGraphNode | null; + recipient?: { label: string; address?: string }; + onClose: () => void; +} + +const InspectorEmpty: React.FC = () => ( +
    + +
    Select a node
    +
    + Click any node on the canvas to read its live budget, gate, and + epoch state. +
    +
    +); + +const InspectorRow: React.FC<{ + icon: string; + title: string; + notes?: (string | undefined | false)[]; + mono?: boolean; + value?: React.ReactNode; +}> = ({ icon, title, notes, mono, value }) => ( +
    + + + +
    +
    + {title} +
    + {notes + ?.filter((n): n is string => Boolean(n)) + .map((n) => ( +
    + {n} +
    + ))} +
    + {value != null && ( +
    + {value} +
    + )} +
    +); + +const StrategyInspector: React.FC<{ + node: IFlowGraphNode; + recipient?: { label: string; address?: string }; +}> = ({ node, recipient }) => { + const state = MM_STATES[node.state]; + const display = node.primitiveKind + ? getStrategyDisplay(node.primitiveKind) + : { icon: 'blockchain-smartcontract' }; + return ( +
    +
    + + + +
    +
    + {node.title} +
    +
    + {state.label} + {node.badge && ( + {node.badge} + )} +
    +
    +
    + + {node.inputs.length > 0 && ( +
    + Inputs +
    + )} + {node.inputs.map((sub) => { + const display = getSubInputDisplay(sub.role, sub.kind); + const closed = sub.status === 'closed'; + return ( + + {sub.reading != null && ( + + )} + {sub.status && ( + + {sub.status} + + )} + {sub.epoch != null && ( + + #{sub.epoch.toLocaleString('en-US')} + + )} + + } + /> + ); + })} + + {node.outputs && node.outputs.length > 0 && ( + <> +
    + Output +
    + {node.outputs.map((out) => ( + + } + /> + ))} + + )} + + {node.params && node.params.length > 0 && ( + <> +
    + Parameters +
    +
    + {node.params.map((p) => ( +
    + + {p.label} + + + {p.value} + +
    + ))} +
    + + )} +
    + ); +}; + +const EndpointInspector: React.FC<{ node: IFlowGraphNode }> = ({ node }) => { + const isSource = node.kind === 'source'; + const display = isSource ? getSourceDisplay() : getRecipientDisplay(); + const tone: MmTone = isSource ? 'neutral' : 'primary'; + return ( +
    +
    + + + +
    +
    + {node.title} +
    + + {isSource ? 'Vault source' : 'Recipient'} + +
    +
    + + {isSource && node.balances && node.balances.length > 0 && ( + <> +
    + Balances +
    + {node.balances.map((b) => ( + } + /> + ))} + + )} + + {!isSource && node.address && ( + <> +
    + Address +
    + + + )} +
    + ); +}; + +export const Inspector: React.FC = (props) => { + const { node, recipient, onClose } = props; + return ( + +
    + + Inspector + + {node && ( + + )} +
    + {!node && } + {node?.kind === 'strategy' && ( + + )} + {node && node.kind !== 'strategy' && ( + + )} +
    + ); +}; + +/* ------------------------------------------------------------------- Legend */ + +const STATE_SWATCH: [label: string, className: string][] = [ + ['Accumulating', 'bg-info-500'], + ['Firing', 'bg-primary-500'], + ['Blocked', 'bg-critical-500'], + ['Done', 'bg-success-600'], + ['Failed', 'bg-critical-700'], + ['Skipped', 'bg-neutral-300'], +]; + +export const Legend: React.FC = () => ( + +
    +
    + Node state +
    +
    + {STATE_SWATCH.map(([label, cls]) => ( + + + {label} + + ))} +
    +
    +
    +
    + Data fidelity +
    +
    + + + real + + + + ~ estimated + + + + opaque + +
    +
    +
    +); + +/* ------------------------------------------------------------- HistoryStrip */ + +/** Static status indicator dot — green / amber / red, no full column. */ +const RUN_STATUS_DOT: Record = { + ok: 'bg-success-500', + partial: 'bg-warning-500', + failed: 'bg-critical-500', +}; + +/** One run's legs as a compact plain-text summary (no tag blobs). */ +const legSummary = (legs?: IHistoryLeg[]): string => { + if (!legs || legs.length === 0) { + return 'All legs ok'; + } + return legs + .map((lg) => { + const detail = lg.detail ?? lg.reason; + return detail ? `${lg.kind} ${detail}` : lg.kind; + }) + .join(' · '); +}; + +export interface IHistoryStripProps { + history: IHistoryRun[]; + activeRun: string | null; + onSelect: (run: string) => void; + /** When provided, renders a collapse chevron in the header (Focus layout). */ + onCollapse?: () => void; +} + +export const HistoryStrip: React.FC = (props) => { + const { history, activeRun, onSelect, onCollapse } = props; + return ( +
    +
    +
    + Dispatch history + + {history.length} + +
    + {/* Exiting replay lives on the ReplayBanner ("Back to live") — no + duplicate control here. */} + {onCollapse && ( + + )} +
    + {history.length === 0 ? ( +
    + No dispatches yet for this flow. +
    + ) : ( +
    + {/* Column header matches the cumulative table header style. + The replay column is an icon per row — no header label. */} +
    + Run + Activity + When + Tx + +
    + {history.map((h) => { + const isActive = activeRun === h.run; + return ( +
    + + + + {h.label} + + + + {legSummary(h.legs)} + + + {h.at} + + + +
    + ); + })} +
    + )} +
    + ); +}; + +/* -------------------------------------------------------------- ReplayBanner */ + +export interface IReplayBannerProps { + run: IHistoryRun; + onClear: () => void; +} + +export const ReplayBanner: React.FC = ({ + run, + onClear, +}) => ( +
    + + + Replaying dispatch{' '} + {run.label} ·{' '} + {run.at} + + +
    +); + +/* -------------------------------------------------------------- SimulateModal */ + +const FidelityAmount: React.FC<{ + amount: number | null; + token: string; + fidelity?: FlowFidelity; +}> = ({ amount, token, fidelity }) => ( + +); + +export interface ISimulateModalProps { + nextRun: INextRun; + onClose: () => void; + onDispatch: () => void; + /** False disables the dispatch action (a no-op dispatch). */ + canDispatch?: boolean; + /** Why dispatch is disabled, surfaced in the modal. */ + dispatchReason?: string; + /** Demo mode: the fork RPC writes go to — shows a compact notice. */ + demoRpc?: string; +} + +export const SimulateModal: React.FC = (props) => { + const { + nextRun, + onClose, + onDispatch, + canDispatch = true, + dispatchReason, + demoRpc, + } = props; + return ( + { + if (!open) { + onClose(); + } + }} + open + > + + +
    + {nextRun.summary} +
    +
    + {nextRun.readyIn && ( + + + ready in{' '} + + {nextRun.readyIn} + + + )} + {nextRun.epoch != null && ( + <> + + + epoch{' '} + + {nextRun.epoch.toLocaleString('en-US')} + + + + )} +
    + +
    + {nextRun.steps.map((s, i) => ( +
    +
    + + {i < nextRun.steps.length - 1 && ( + + )} +
    +
    + {/* No "will fire" badge for standard execution — + the filled dot already signals it. Skips show + a single inline alert below instead. */} +
    + + {s.kind} + +
    + {/* A firing step shows what moves; a skipped + step shows only WHY (no opaque "on fill" + placeholder amounts for something that won't + run). */} + {s.willFire ? ( +
    + {s.ins.map((io) => ( + + ))} + + {s.outs.map((io) => ( + + ))} +
    + ) : ( +
    + + + Skipped + + {s.skipReason && ( + + {s.skipReason} + + )} +
    + )} +
    +
    + ))} +
    + + {nextRun.net.length > 0 && ( +
    +
    + Net to the DAO vault +
    +
    + {nextRun.net.map((entry) => { + const positive = entry.delta >= 0; + const pending = + entry.opaque && entry.delta === 0; + return ( +
    + + {entry.token} + + {pending ? ( + + on fill + + ) : ( + + {positive ? '+' : '−'} + {entry.opaque ? '~' : ''} + {Math.abs( + entry.delta, + ).toLocaleString('en-US', { + maximumFractionDigits: 4, + })} + + )} +
    + ); + })} +
    +
    + )} + + {!canDispatch && dispatchReason && ( +
    + + {dispatchReason} +
    + )} + {demoRpc && ( +
    + + + Demo mode — dispatches to the fork ({demoRpc}). No + real funds move. + +
    + )} +
    + { + onClose(); + onDispatch(); + }, + }} + secondaryAction={{ label: 'Close', onClick: onClose }} + /> +
    + ); +}; diff --git a/src/modules/flow/components/lidoMoneyMachine/ActionsMenu.tsx b/src/modules/flow/components/lidoMoneyMachine/ActionsMenu.tsx new file mode 100644 index 0000000000..86e77f64e9 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/ActionsMenu.tsx @@ -0,0 +1,118 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. + +// "More actions ▾" — secondary commands dropdown in the header. +// +// Each item fires an action with a sensible default value (kept consistent +// with the corresponding `just demo-*` recipe). We don't expose per-call +// input boxes here on purpose: the CLI is where you go for tweaks; the UI +// surfaces the canonical buttons. Items are passed as `groups` — sibling +// items share a group; groups render with a divider between them. + +import { useEffect, useRef, useState } from 'react'; + +export type ActionItem = { + key: string; + label: string; + description?: string; + run: () => Promise; +}; + +export function ActionsMenu({ + groups, + busyKey, + disabled, +}: { + groups: ActionItem[][]; + busyKey: string | null; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + function onClick(e: MouseEvent) { + if (!(e.target instanceof Element)) { + return; + } + if (!rootRef.current?.contains(e.target)) { + setOpen(false); + } + } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') { + setOpen(false); + } + } + document.addEventListener('mousedown', onClick); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onClick); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const nonEmpty = groups.filter((g) => g.length > 0); + + return ( +
    + + {open && ( +
    + {nonEmpty.map((group, gi) => ( +
      + {group.map((it) => { + const busy = busyKey === it.key; + return ( +
    • + +
    • + ); + })} +
    + ))} +
    + )} +
    + ); +} diff --git a/src/modules/flow/components/lidoMoneyMachine/DispatchDialog.tsx b/src/modules/flow/components/lidoMoneyMachine/DispatchDialog.tsx new file mode 100644 index 0000000000..caa56a7eb7 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/DispatchDialog.tsx @@ -0,0 +1,117 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. + +// Confirm-dispatch modal: shows the predicted Steps view alongside a +// Confirm / Cancel. Driven by the latest simulation the Status panel has +// already fetched. + +import { useEffect } from 'react'; +import type { FlowGraph } from '@/shared/lidoPreview'; +import { CloseIcon } from './icons'; +import { StepsView } from './StepsView'; + +export function DispatchDialog({ + flow, + simulationError, + busy, + txError, + onConfirm, + onCancel, +}: { + flow: FlowGraph | null; + simulationError?: string; + busy: boolean; + txError: string | null; + onConfirm: () => void; + onCancel: () => void; +}) { + // Close on Escape (when not mid-tx). + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape' && !busy) { + onCancel(); + } + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [busy, onCancel]); + + return ( +
    { + if (e.target === e.currentTarget && !busy) { + onCancel(); + } + }} + > +
    +
    +

    Confirm dispatch

    + +
    +
    + {simulationError && ( +
    + Simulation failed: {simulationError} +
    + Dispatch may still succeed on chain, but the + preview can't show what'll happen. +
    +
    + )} + {!simulationError && !flow && ( +
    Computing the next dispatch…
    + )} + {flow && ( +
    + +
    + )} +
    +
    + {txError && ( +
    + Dispatch failed: {txError} +
    + )} +
    + + +
    +
    +
    +
    + ); +} diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx new file mode 100644 index 0000000000..f4f76910f1 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx @@ -0,0 +1,199 @@ +// LMM_DEMO_HACK: the "cheats" dropdown that lets a demo presenter warp +// time, top up balances, settle a CoW order, etc., directly against the +// Anvil fork. Visible only when `LMM_DEMO_MODE === true` *and* the +// manifest is loaded. Each action delegates to the vendored +// `lidoMoneyMachine/actions.ts` so the behavior matches Jordi's CLI demo. + +'use client'; + +import { useMemo, useState } from 'react'; +import { createPublicClient, http, type PublicClient, parseEther } from 'viem'; +import { mainnet } from 'viem/chains'; +import { LMM_DEMO_MODE, LMM_RPC_URL } from '@/modules/flow/demo/lmmDemoConfig'; +import { assertForkRpc } from '@/modules/flow/demo/safety'; +import { useLmmManifest } from '@/modules/flow/demo/useLmmManifest'; +import { type ActionItem, ActionsMenu } from './ActionsMenu'; +import { + type ActionContext, + deriveAddressesFromManifest, + dispatchAction, + refreshOracle, + setEthPrice, + setTargetEpoch, + settleCowSwapAuto, + topUpLdo, + topUpStEth, + warpAction, +} from './actions'; +import './styles.css'; + +let cachedClient: PublicClient | undefined; +const getPublicClient = (): PublicClient => { + if (cachedClient) { + return cachedClient; + } + cachedClient = createPublicClient({ + chain: mainnet, + transport: http(LMM_RPC_URL), + }); + return cachedClient; +}; + +export const LmmCheatsMenu: React.FC = () => { + const { manifest } = useLmmManifest(); + const [busyKey, setBusyKey] = useState(null); + + const ctx = useMemo(() => { + if (!manifest) { + return undefined; + } + const addresses = deriveAddressesFromManifest( + manifest as unknown as Parameters< + typeof deriveAddressesFromManifest + >[0], + ); + if (!addresses) { + return undefined; + } + return { + rpc: LMM_RPC_URL, + publicClient: getPublicClient(), + dao: manifest.lmm.dao, + dispatcher: manifest.lmm.dispatcherPlugin, + addresses, + }; + }, [manifest]); + + const runAction = (key: string, fn: () => Promise) => async () => { + setBusyKey(key); + try { + assertForkRpc(); + await fn(); + } catch (e) { + // Stay silent in the UI — the menu is a presenter tool, the + // status panel below will reflect any state changes (or lack + // thereof) on the next refresh. We still log to the console + // so the presenter can investigate in devtools. `noConsole` + // is disabled for the vendored Lido directory in biome.json. + console.error(`[lmm-demo] action ${key} failed:`, e); + } finally { + setBusyKey(null); + } + }; + + if (!LMM_DEMO_MODE || !ctx) { + return null; + } + + const groups: ActionItem[][] = [ + [ + { + key: 'dispatch', + label: 'Dispatch now', + description: 'Calls dispatcher.dispatch() against the fork.', + run: runAction('dispatch', () => dispatchAction(ctx)), + }, + ], + [ + { + key: 'warp-1d', + label: 'Warp +1 day', + description: + 'Advance the fork clock by 24h, mine 1 block, refresh oracle.', + // Chain refresh after the warp so the staleness check (max + // 3600s) doesn't no-op the UniV2 LP + CowSwap legs on the + // next dispatch — see actions.ts → refreshOracle. + run: runAction('warp-1d', async () => { + await warpAction(ctx, 24 * 60 * 60); + await refreshOracle(ctx); + }), + }, + { + key: 'warp-7d', + label: 'Warp +7 days', + description: + 'Advance the fork clock by 7 days + refresh oracle.', + run: runAction('warp-7d', async () => { + await warpAction(ctx, 7 * 24 * 60 * 60); + await refreshOracle(ctx); + }), + }, + { + key: 'refresh-oracle', + label: 'Refresh oracle (only)', + description: + 'Re-stamp updatedAt on every oracle pair without touching prices.', + run: runAction('refresh-oracle', () => refreshOracle(ctx)), + }, + ], + [ + { + key: 'top-up-steth', + label: 'Top up 100 stETH', + description: 'Impersonate the stETH whale and transfer to DAO.', + run: runAction('top-up-steth', () => + topUpStEth(ctx, parseEther('100')), + ), + }, + { + key: 'top-up-ldo', + label: 'Top up 1000 LDO', + description: 'Impersonate Lido Agent and transfer LDO to DAO.', + run: runAction('top-up-ldo', () => + topUpLdo(ctx, parseEther('1000')), + ), + }, + ], + [ + { + key: 'set-eth-price', + label: 'Set ETH/USD to $3 000', + description: + 'Updates the mock oracle so the PriceFloorGate opens.', + run: runAction('set-eth-price', () => setEthPrice(ctx, 3000)), + }, + { + key: 'set-target-epoch', + label: 'Bump target epoch +1', + description: 'Increment StreamUntilBudget.targetEpoch by one.', + run: runAction('set-target-epoch', () => + setTargetEpoch(ctx, 1), + ), + }, + ], + [ + { + key: 'settle-cowswap', + label: 'Settle CoW order (mock)', + description: + 'Fills the pending presigned order (sell-side = current wstETH allowance).', + // Sizes the fill from the *current* on-chain allowance the + // DAO granted to the settlement contract (= the size of + // the latest presigned order). Hardcoding `parseEther('1')` + // here used to revert with `transfer amount exceeds + // allowance` after the stream's per-dispatch budget + // dropped below 1 wstETH — see commit history. + run: runAction('settle-cowswap', async () => { + const result = await settleCowSwapAuto(ctx); + if (result === null) { + console.warn( + '[lmm-demo] settle-cowswap: no pending order (allowance is 0). Run a dispatch first.', + ); + } + }), + }, + ], + ]; + + return ( +
    + +
    + ); +}; diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx new file mode 100644 index 0000000000..3793e55e7c --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx @@ -0,0 +1,93 @@ +// LMM_DEMO_HACK: client-side wrapper that renders the LMM topology + live +// state card. Production builds NEVER mount this — the FlowPolicyTree +// route guards on isLmmDemoDao() before importing. +// +// The previous version owned its own `inspect()` + `useStatus` poll loop. +// We lifted both into `useLmmLiveSnapshot` (mounted from FlowDataProvider) +// so KPI / Activity / Topology share one subscription and one RPC poll +// per session. + +'use client'; + +import classNames from 'classnames'; +import type { Address } from 'viem'; +import { LMM_RPC_URL } from '@/modules/flow/demo/lmmDemoConfig'; +import { useFlowDataContext } from '@/modules/flow/providers/flowDataProvider'; +import { StatusPanel } from './StatusPanel'; +import { TopologyView } from './TopologyView'; + +interface ILmmPolicyTopologyProps { + /** DispatcherPlugin address (i.e. the top-level multi-dispatch policy). + * Kept on the props for backwards-compatibility with callers; the + * effective address now lives on the manifest. Treated as an + * assertion that the provider's snapshot belongs to this plugin. */ + pluginAddress: Address; + /** Optional Lido DAO address rendered as a parent node above the LMM DAO. */ + lidoDaoAddress?: string; + /** Optional address to pre-select in the topology — used by the dashboard + * orchestrator chip deep-link (`?node=`). */ + selectedNodeAddress?: string; + className?: string; +} + +export const LmmPolicyTopology: React.FC = (props) => { + const { lidoDaoAddress, selectedNodeAddress, className } = props; + const { liveSnapshot } = useFlowDataContext(); + const topology = liveSnapshot?.topology ?? null; + const statusSnapshot = liveSnapshot?.snapshot ?? undefined; + const error = liveSnapshot?.error; + + if (error) { + return ( +
    +
    + Topology inspection failed: {error} +
    +
    + ); + } + + if (!topology) { + return ( +
    +
    + Loading topology from {LMM_RPC_URL}… +
    +
    + ); + } + + return ( +
    +
    + +
    +
    + +
    +
    + ); +}; diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx new file mode 100644 index 0000000000..5274b7f344 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx @@ -0,0 +1,296 @@ +'use client'; + +// Aragon-styled per-leg cards for the LMM dispatch dialog. +// +// Each step in the simulator's FlowGraph renders as a card with two columns +// (In / Out) plus a status chip — no before/after deltas, no green/red +// success/error tone (those carry no meaning when both columns are token +// movements). Underneath the three cards we summarise the net effect on +// the DAO across all legs. + +import { Card } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import type { + FlowGraph, + Step, + StepStatus, + TokenInfo, +} from '@/shared/lidoPreview'; +import { formatAmount, shortAddress } from './format'; + +export interface ILmmSimulationCardsProps { + flow: FlowGraph; + className?: string; +} + +interface ITokenMove { + /** Display label; for outbound transfers the recipient or the call's + * verb, for produces the call's verb prefixed with `+`. */ + label?: string; + amount: bigint; + token: TokenInfo; + sign: '+' | '-'; +} + +const stepKindLabel = (kind: string): string => { + switch (kind) { + case 'strategy.dispatch.transfer': + return 'Transfer'; + case 'strategy.dispatch.burn': + return 'Burn'; + case 'strategy.dispatch.epoch-transfer': + return 'Epoch transfer'; + case 'strategy.dispatch.lido.wrap': + return 'Wrap · stETH → wstETH'; + case 'strategy.dispatch.lido.univ2-liquidity': + return 'UniV2 LP · wstETH + LDO → LP'; + case 'strategy.dispatch.lido.gated-cowswap': + return 'CowSwap · wstETH → LDO buyback'; + default: + return kind; + } +}; + +const statusChipClass = (status: StepStatus): string => { + // Neutral palette only — no green/red tones, no warm tints. Status is + // a label, not a value judgement. + switch (status) { + case 'executed': + return 'border-primary-200 bg-primary-50 text-primary-800'; + case 'no-op': + return 'border-neutral-100 bg-neutral-50 text-neutral-600'; + case 'skipped-paused': + return 'border-neutral-200 bg-neutral-50 text-neutral-700'; + case 'opaque': + case 'downstream-opaque': + return 'border-neutral-200 bg-neutral-0 text-neutral-700'; + default: + return 'border-neutral-100 bg-neutral-0 text-neutral-700'; + } +}; + +const statusChipLabel = (status: StepStatus): string => { + switch (status) { + case 'executed': + return 'Execute'; + case 'no-op': + return 'No-op'; + case 'skipped-paused': + return 'Skipped · paused'; + case 'opaque': + return 'Opaque'; + case 'downstream-opaque': + return 'Downstream opaque'; + default: + return status; + } +}; + +const stepMoves = (step: Step): { ins: ITokenMove[]; outs: ITokenMove[] } => { + const ins: ITokenMove[] = []; + const outs: ITokenMove[] = []; + // ERC-20 transfers from DAO → recipient. + for (const t of step.transfers) { + outs.push({ + label: shortAddress(t.to), + amount: t.amount, + token: t.token, + sign: '-', + }); + } + // External calls — consumes = "In" (sent to the contract), produces = "Out" + // (returned to the DAO). + for (const call of step.externalCalls) { + const verb = call.description.split('(')[0] || 'call'; + for (const c of call.consumes) { + ins.push({ + label: verb, + amount: c.amount, + token: c.token, + sign: '-', + }); + } + for (const p of call.produces) { + ins.push({ + label: `+${verb}`, + amount: p.amount, + token: p.token, + sign: '+', + }); + } + } + return { ins, outs }; +}; + +export const LmmSimulationCards: React.FC = ({ + flow, + className, +}) => ( +
    +
      + {flow.steps.map((step) => ( +
    1. + +
    2. + ))} +
    + +
    +); + +const StepCard: React.FC<{ step: Step }> = ({ step }) => { + const { ins, outs } = stepMoves(step); + return ( + +
    +
    + + #{step.index} + + + {stepKindLabel(step.strategyRef.kind)} + +
    + + {statusChipLabel(step.status)} + +
    + + {step.reason && ( +

    + {step.reason} +

    + )} + + {(ins.length > 0 || outs.length > 0) && ( +
    + + +
    + )} + + {ins.length === 0 && + outs.length === 0 && + step.status !== 'no-op' && + step.status !== 'skipped-paused' && ( +

    + No on-chain effects. +

    + )} +
    + ); +}; + +const Column: React.FC<{ + title: string; + moves: ITokenMove[]; + placeholder: string; +}> = ({ title, moves, placeholder }) => ( +
    + + {title} + + {moves.length === 0 ? ( + + {placeholder} + + ) : ( +
      + {moves.map((m, i) => ( +
    • + + {m.sign} + + {formatAmount(m.amount, m.token.decimals, 4)} + + + {m.token.symbol ?? + shortAddress(m.token.address)} + + + {m.label && ( + + {m.label} + + )} +
    • + ))} +
    + )} +
    +); + +const NetEffectCard: React.FC<{ flow: FlowGraph }> = ({ flow }) => { + const initial = new Map(); + for (const b of flow.initialState.balances) { + initial.set(b.token.address.toLowerCase(), { + token: b.token, + amount: b.amount, + }); + } + const final = new Map(); + for (const b of flow.finalState.balances) { + final.set(b.token.address.toLowerCase(), { + token: b.token, + amount: b.amount, + }); + } + const tokens = new Set([...initial.keys(), ...final.keys()]); + const rows: Array<{ token: TokenInfo; delta: bigint }> = []; + for (const key of tokens) { + const before = initial.get(key)?.amount ?? 0n; + const after = final.get(key)?.amount ?? 0n; + if (before === after) { + continue; + } + const token = final.get(key)?.token ?? initial.get(key)!.token; + rows.push({ token, delta: after - before }); + } + if (rows.length === 0) { + return null; + } + return ( + +

    + Net effect on DAO +

    +
      + {rows.map((r) => { + const isNeg = r.delta < 0n; + const absDelta = isNeg ? -r.delta : r.delta; + return ( +
    • + + {r.token.symbol ?? + shortAddress(r.token.address)} + + + {isNeg ? '−' : '+'} + {formatAmount(absDelta, r.token.decimals, 4)} + +
    • + ); + })} +
    +
    + ); +}; diff --git a/src/modules/flow/components/lidoMoneyMachine/NodeDetails.tsx b/src/modules/flow/components/lidoMoneyMachine/NodeDetails.tsx new file mode 100644 index 0000000000..ae6e937855 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/NodeDetails.tsx @@ -0,0 +1,484 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. + +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import type { GraphNode } from '@/shared/lidoPreview'; +import { formatAmount, shortAddress } from './format'; +import { CloseIcon } from './icons'; +import type { StatusSnapshot } from './useStatus'; + +type TokenData = { + address: string; + symbol: string | null; + decimals: number | null; +}; + +type RatioEntry = { recipient: string; ratio: number }; + +export function NodeDetails({ + node, + status, + onClose, +}: { + node: GraphNode; + status?: StatusSnapshot; + onClose: () => void; +}) { + return ( + + ); +} + +// --- Layout helpers -------------------------------------------------------- + +function Row({ label, v }: { label: string; v: ReactNode }) { + return ( + <> +
    {label}
    +
    {v}
    + + ); +} + +function Addr({ a }: { a: string | null | undefined }) { + const [copied, setCopied] = useState(false); + const timer = useRef | null>(null); + + // Clear the timer if the component unmounts while the toast is showing. + useEffect( + () => () => { + if (timer.current) { + clearTimeout(timer.current); + } + }, + [], + ); + + if (!a) { + return ; + } + + function onClick() { + void navigator.clipboard?.writeText(a!); + setCopied(true); + if (timer.current) { + clearTimeout(timer.current); + } + timer.current = setTimeout(() => setCopied(false), 1800); + } + + return ( + + + {copied && ( + + Copied + {a} + + )} + + ); +} + +function Mono({ children }: { children: ReactNode }) { + return {children}; +} + +function tokenLabel(t: TokenData): string { + if (!t) { + return '?'; + } + const sym = t.symbol ?? shortAddress(t.address); + const dec = t.decimals !== null ? ` (${t.decimals} decimals)` : ''; + return `${sym}${dec}`; +} + +function FailsafeDecoded({ + bitmap, + count, +}: { + bitmap: unknown; + count: unknown; +}) { + const bi = toBigInt(bitmap); + const n = typeof count === 'number' ? count : Number(count ?? 0); + + if (bi === null) { + return ; + } + if (bi === 0n) { + return none; + } + if (n > 0 && bi === (1n << BigInt(n)) - 1n) { + return ( + + all ({n}) + + ); + } + + const indices: number[] = []; + const bitWidth = Math.max(n, bi.toString(2).length); + for (let i = 0; i < bitWidth; i++) { + if ((bi & (1n << BigInt(i))) !== 0n) { + indices.push(i); + } + } + return {indices.map((i) => `#${i}`).join(', ')}; +} + +function toBigInt(raw: unknown): bigint | null { + if (raw === null || raw === undefined) { + return null; + } + if (typeof raw === 'bigint') { + return raw; + } + try { + return BigInt(String(raw)); + } catch { + return null; + } +} + +// --- Per-kind rendering ---------------------------------------------------- + +function titleFor(node: GraphNode): string { + switch (node.type) { + case 'plugin.dispatch': + return 'Dispatcher plugin'; + case 'plugin.unknown': + return 'Unknown plugin'; + case 'dao': + return 'LMM DAO'; + case 'lido-dao': + return 'Lido DAO'; + case 'strategy.dispatch.transfer': + return 'Transfer strategy'; + case 'strategy.dispatch.burn': + return 'Burn strategy'; + case 'strategy.dispatch.epoch-transfer': + return 'Epoch-transfer strategy'; + case 'strategy.dispatch.lido.wrap': + return 'Wrap strategy (Lido)'; + case 'strategy.dispatch.lido.univ2-liquidity': + return 'UniV2 add-liquidity (Lido)'; + case 'strategy.dispatch.lido.gated-cowswap': + return 'Gated CowSwap (Lido)'; + case 'strategy.unknown': + return 'Unknown strategy'; + case 'budget.full': + return 'Full budget'; + case 'budget.required': + return 'Required budget'; + case 'budget.lido.stream-until': + return 'Stream-until budget (Lido)'; + case 'budget.unknown': + return 'Unknown budget'; + case 'splitter.solo': + return 'Solo splitter'; + case 'splitter.equal': + return 'Equal splitter'; + case 'splitter.ratio': + return 'Ratio splitter'; + case 'splitter.unknown': + return 'Unknown splitter'; + case 'lido.price-floor-gate': + return 'Price-floor gate (Lido)'; + case 'epoch-provider': + return 'Epoch provider'; + case 'recipient': + return 'Recipient'; + default: + return node.type; + } +} + +function renderRows(node: GraphNode, status?: StatusSnapshot): ReactNode { + const d = node.data as Record; + const rows: ReactNode[] = []; + const push = (label: string, v: ReactNode) => + rows.push(); + + // Every node has an address + if (typeof d.address === 'string') { + push('Address', ); + } + + switch (node.type) { + case 'dao': { + // For the DAO node, show whatever token balances the status snapshot + // has fetched. If status isn't loaded yet (user hit Inspect but + // hasn't opened Status), surface a hint instead. + if (!status) { + push( + 'Balances', + + open the Status panel to fetch live balances + , + ); + break; + } + const daoLower = (d.address as string).toLowerCase(); + if (status.dao.toLowerCase() !== daoLower) { + push( + 'Balances', + + DAO in topology differs from the status snapshot + , + ); + break; + } + for (const b of status.balances) { + push( + b.token.symbol ?? shortAddress(b.token.address), + + {formatAmount(b.amount, b.token.decimals)}{' '} + {b.token.symbol ?? ''} + , + ); + } + break; + } + + case 'lido-dao': { + // The Lido DAO (Lido Agent) owns the LMM and is the lpRecipient on + // the UniV2 strategy, so its LP balance is the one position we can + // surface here directly from the status snapshot. + if (!status?.lp?.pair) { + push( + 'LP', + + pair not deployed — LP appears once UniV2 fires + , + ); + break; + } + push( + 'LP held', + {formatAmount(status.lp.recipientBalance, 18)} LP, + ); + break; + } + + case 'plugin.dispatch': + push('Plugin ID', {String(d.pluginId)}); + push('DAO', ); + push('Strategies', String(d.strategyCount)); + push( + 'Failsafe', + , + ); + break; + + case 'plugin.unknown': + push('Plugin ID', {String(d.pluginId ?? '—')}); + if (d.dao) { + push('DAO', ); + } + break; + + case 'strategy.dispatch.transfer': + push('Strategy ID', {String(d.strategyId)}); + push('Paused', d.paused ? 'yes' : 'no'); + push('Use safe transfer', d.useSafeTransfer ? 'yes' : 'no'); + break; + + case 'strategy.dispatch.burn': + push('Strategy ID', {String(d.strategyId)}); + push('Paused', d.paused ? 'yes' : 'no'); + break; + + case 'strategy.dispatch.epoch-transfer': + push('Strategy ID', {String(d.strategyId)}); + push('Paused', d.paused ? 'yes' : 'no'); + push('Use safe transfer', d.useSafeTransfer ? 'yes' : 'no'); + push('Last dispatched epoch', String(d.lastDispatchedEpoch)); + break; + + case 'strategy.dispatch.lido.wrap': + push('Strategy ID', {String(d.strategyId)}); + push('Paused', d.paused ? 'yes' : 'no'); + push('Target wstETH', ); + break; + + case 'strategy.dispatch.lido.univ2-liquidity': + push('Strategy ID', {String(d.strategyId)}); + push('Paused', d.paused ? 'yes' : 'no'); + push('Router', ); + push('Oracle', ); + push('LP recipient', ); + push( + 'Max slippage', + `${(Number(d.maxSlippageBps) / 100).toFixed(2)}%`, + ); + push('Max staleness', `${String(d.maxStaleness)}s`); + push('Last epoch fired', String(d.lastEpoch)); + break; + + case 'strategy.dispatch.lido.gated-cowswap': { + const t = d.targetToken as TokenData; + push('Strategy ID', {String(d.strategyId)}); + push('Paused', d.paused ? 'yes' : 'no'); + push('Target token', tokenLabel(t)); + push('Target token address', ); + push( + 'CowSwap settlement', + , + ); + push('Price oracle', ); + push( + 'Max slippage', + `${(Number(d.maxSlippageBps) / 100).toFixed(2)}%`, + ); + push('Max staleness', `${String(d.maxStaleness)}s`); + push('Safe approval', d.useSafeApproval ? 'yes' : 'no'); + push('Last epoch fired', String(d.lastEpoch)); + break; + } + + case 'strategy.unknown': + push('Strategy ID', {String(d.strategyId)}); + push('Paused', d.paused ? 'yes' : 'no'); + break; + + case 'budget.full': { + const t = d.token as TokenData; + push('Budget ID', {String(d.budgetId)}); + push('Vault', ); + push('Token', tokenLabel(t)); + push('Token address', ); + break; + } + + case 'budget.required': { + const t = d.token as TokenData; + push('Budget ID', {String(d.budgetId)}); + push('Vault', ); + push('Token', tokenLabel(t)); + push('Token address', ); + push( + 'Required amount', + + {formatAmount( + d.requiredAmount as bigint | string, + t.decimals, + )}{' '} + {t.symbol ?? ''} + , + ); + break; + } + + case 'budget.lido.stream-until': { + const t = d.token as TokenData; + push('Budget ID', {String(d.budgetId)}); + push('Vault', ); + push('Token', tokenLabel(t)); + push('Token address', ); + push('Target epoch', String(d.targetEpoch)); + push('Floor epochs', String(d.floorEpochs)); + break; + } + + case 'budget.unknown': { + const t = d.token as TokenData; + push('Budget ID', {String(d.budgetId)}); + push('Token', tokenLabel(t)); + break; + } + + case 'lido.price-floor-gate': + push('Vault', ); + push('Oracle', ); + push('Sell token', ); + push('Buy token', ); + push('Threshold', {String(d.threshold)}); + push('Max staleness', `${String(d.maxStaleness)}s`); + break; + + case 'splitter.solo': + push('Splitter ID', {String(d.splitterId)}); + push('Recipient', ); + break; + + case 'splitter.equal': { + const recipients = (d.recipients as string[]) ?? []; + push('Splitter ID', {String(d.splitterId)}); + push('Recipients', String(recipients.length)); + push( + 'Allocation', +
      + {recipients.map((r, i) => ( +
    • + + + {recipients.length > 0 + ? `${(100 / recipients.length).toFixed(2)}%` + : ''} + +
    • + ))} +
    , + ); + break; + } + + case 'splitter.ratio': { + const entries = (d.entries as RatioEntry[]) ?? []; + push('Splitter ID', {String(d.splitterId)}); + push('Recipients', String(entries.length)); + push( + 'Allocation', +
      + {entries.map((e, i) => ( +
    • + + + {(e.ratio / 10_000).toFixed(2)}% + +
    • + ))} +
    , + ); + break; + } + + case 'splitter.unknown': + push('Splitter ID', {String(d.splitterId)}); + break; + + case 'epoch-provider': + push('Current epoch', String(d.currentEpoch)); + break; + + case 'recipient': + // Address is already in the common section + break; + + default: + break; + } + + return <>{rows}; +} diff --git a/src/modules/flow/components/lidoMoneyMachine/StatusPanel.tsx b/src/modules/flow/components/lidoMoneyMachine/StatusPanel.tsx new file mode 100644 index 0000000000..72d0f1cc98 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/StatusPanel.tsx @@ -0,0 +1,12 @@ +// LMM_DEMO_HACK: thin wrapper around StatusView. Used to host a custom +// resizable chrome; we removed that to keep the page short and match the +// Aragon visual language. Kept as a separate component so the +// `LmmPolicyTopology` callsite stays familiar and future demo-only chrome +// (e.g. a refresh button) has a place to live. + +import { StatusView } from './StatusView'; +import type { StatusSnapshot } from './useStatus'; + +export const StatusPanel: React.FC<{ snapshot?: StatusSnapshot }> = ({ + snapshot, +}) => ; diff --git a/src/modules/flow/components/lidoMoneyMachine/StatusView.tsx b/src/modules/flow/components/lidoMoneyMachine/StatusView.tsx new file mode 100644 index 0000000000..5474dc5926 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/StatusView.tsx @@ -0,0 +1,285 @@ +// LMM_DEMO_HACK: live state column rendered as three plain `Card`s with +// white headers — no `Accordion`, no gradient, no resizable chrome. The +// sidebar lives to the right of the topology graph and stays compact on +// large viewports. +// +// Production removal: drop this whole file with the rest of the LMM +// demo surface. See docs/lido-mmd-status.md `live-snapshot-rpc` — the +// indexer-emitted snapshot entity will feed an equivalent gov-ui-kit +// Card cluster. + +import { Card, DefinitionList } from '@aragon/gov-ui-kit'; +import type { ReactNode } from 'react'; +import { formatAmount, shortAddress } from './format'; +import type { StatusSnapshot } from './useStatus'; + +export interface IStatusViewProps { + snapshot?: StatusSnapshot; +} + +// Compact grid: term column gets ~40% of the row, value column the rest. +// `text-xs` everywhere so the sidebar can host 12+ rows without dwarfing +// the topology column on the left. `*:!whitespace-normal` lets long +// descriptions (settlement addr, price · threshold) wrap to two lines +// instead of being truncated to "ora...". +const ROW_PADDING = + '!py-1.5 !px-3 !gap-x-3 !grid-cols-[minmax(7rem,2fr)_minmax(0,3fr)] !text-xs [&_dt]:!text-xs [&_dt]:leading-tight [&_dd]:!text-xs [&_dd]:leading-tight [&_p]:!whitespace-normal [&_p]:!text-[11px] [&_p]:!leading-snug'; + +export const StatusView: React.FC = ({ snapshot }) => { + if (snapshot == null) { + return ( +
    + +

    + Loading live state… +

    +
    +
    + ); + } + return ( +
    + + + + + + + + + + +
    + ); +}; + +const SectionHeader: React.FC<{ title: string; subtitle: string }> = ({ + title, + subtitle, +}) => ( +
    + + {title} + + + {subtitle} + +
    +); + +const SectionCard: React.FC<{ title: string; children: ReactNode }> = ({ + title, + children, +}) => ( + +
    + + {title} + +
    +
    {children}
    +
    +); + +// --- Sections -------------------------------------------------------------- + +const BalancesSection: React.FC<{ snapshot: StatusSnapshot }> = ({ + snapshot, +}) => ( + + {snapshot.balances.map((b) => ( + + + + ))} + {snapshot.lp && ( + + {snapshot.lp.pair ? ( + + ) : ( + + Pair not deployed yet + + )} + + )} + +); + +const BudgetsSection: React.FC<{ snapshot: StatusSnapshot }> = ({ + snapshot, +}) => ( + + {snapshot.budgets.map((b, i) => ( + + + + ))} + {snapshot.stream && ( + + + {snapshot.stream.remaining.toString()} ep · floor{' '} + {snapshot.stream.floorEpochs} + + + )} + +); + +const ConditionsSection: React.FC<{ snapshot: StatusSnapshot }> = ({ + snapshot, +}) => { + const items: ReactNode[] = []; + if (snapshot.gate) { + const g = snapshot.gate; + const PRICE_DECIMALS = 6; + const age = + g.updatedAt !== null + ? Math.max(0, Number(snapshot.blockTimestamp - g.updatedAt)) + : null; + const stale = + age !== null && g.maxStaleness > 0n && BigInt(age) > g.maxStaleness; + items.push( + + + , + ); + if (age !== null) { + items.push( + + + {age}s ago + + , + ); + } + } + if (snapshot.cowSwap) { + const cs = snapshot.cowSwap; + items.push( + + + {cs.ordersPlaced !== null + ? cs.ordersPlaced.toString() + : '—'} + + , + + + , + ); + } + if (items.length === 0) { + return ( +

    + No execution conditions configured for this dispatcher. +

    + ); + } + return ( + + {items} + + ); +}; + +// --- Atoms ----------------------------------------------------------------- + +const Amount: React.FC<{ + amount: bigint; + decimals: number | null; + token: string | null; +}> = ({ amount, decimals, token }) => ( + + {formatAmount(amount, decimals, 4)} + {token && ( + {token} + )} + +); + +const StatusPill: React.FC<{ on: boolean }> = ({ on }) => ( + + {on ? 'open' : 'closed'} + +); + +function tokenName(t: { symbol: string | null; address: string }): string { + return t.symbol ?? shortAddress(t.address); +} + +function budgetKindShort(kind: string): string { + switch (kind) { + case 'budget.full': + return 'Full'; + case 'budget.required': + return 'Required'; + case 'budget.lido.stream-until': + return 'Stream'; + case 'budget.unknown': + return 'Unknown'; + default: + return kind; + } +} diff --git a/src/modules/flow/components/lidoMoneyMachine/StepsView.tsx b/src/modules/flow/components/lidoMoneyMachine/StepsView.tsx new file mode 100644 index 0000000000..92fea15d2e --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/StepsView.tsx @@ -0,0 +1,241 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. + +// Card-strip view of the FlowGraph. Extracted from the original FlowView so +// it can sit alongside the graph view behind a tab switcher. + +import type { FlowGraph, Step, TokenBalance } from '@/shared/lidoPreview'; +import { formatAmount, shortAddress } from './format'; + +export function StepsView({ flow }: { flow: FlowGraph }) { + return ( +
    + {flow.steps.map((step) => ( + + ))} +
    + ); +} + +function StepCard({ step }: { step: Step }) { + const hasAction = + step.transfers.length > 0 || step.externalCalls.length > 0; + + return ( +
    +
    + #{step.index} + + {strategyKindLabel(step.strategyRef.kind)} + + + {stepStatusLabel(step.status)} + +
    + + {step.reason &&
    {step.reason}
    } + + {hasAction && ( +
    + + +
    + )} + + {!hasAction && + step.status !== 'no-op' && + step.status !== 'skipped-paused' && ( +
    (no actions)
    + )} + + {hasBalanceChange(step) && ( +
    + +
    + )} +
    + ); +} + +function SourceRow({ step }: { step: Step }) { + const { amount, token } = step.budget ?? {}; + return ( +
    + + + DAO + + {amount !== undefined && token && ( + + {formatAmount(amount, token.decimals)} {token.symbol ?? ''} + + )} +
    + ); +} + +type Branch = { + kind: 'transfer' | 'burn' | 'call' | 'produce'; + amount: string; + token: string; + target: string; +}; + +function BranchList({ step }: { step: Step }) { + const branches = branchesForStep(step); + if (branches.length === 0) { + return null; + } + return ( +
      + {branches.map((b, i) => ( +
    • + + {b.amount} + {b.token ? ` ${b.token}` : ''} + + + {b.target} + +
    • + ))} +
    + ); +} + +function branchesForStep(step: Step): Branch[] { + const out: Branch[] = []; + + for (const t of step.transfers) { + out.push({ + kind: 'transfer', + amount: formatAmount(t.amount, t.token.decimals, 4), + token: t.token.symbol ?? '', + target: shortAddress(t.to), + }); + } + + for (const call of step.externalCalls) { + const verb = call.description.split('(')[0] || 'call'; + for (const c of call.consumes) { + out.push({ + kind: verb === 'burn' ? 'burn' : 'call', + amount: formatAmount(c.amount, c.token.decimals, 4), + token: c.token.symbol ?? '', + target: verb, + }); + } + for (const p of call.produces) { + out.push({ + kind: 'produce', + amount: `+${formatAmount(p.amount, p.token.decimals, 4)}`, + token: p.token.symbol ?? '', + target: verb, + }); + } + if (call.consumes.length === 0 && call.produces.length === 0) { + out.push({ + kind: 'call', + amount: '', + token: '', + target: call.description, + }); + } + } + + return out; +} + +function BalanceDeltaList({ + before, + after, +}: { + before: TokenBalance[]; + after: TokenBalance[]; +}) { + // Only surface tokens whose amount actually moves. An unchanged + // `X → X` line is just noise — the step header already shows the + // strategy ran, and a same-value arrow buries the real deltas below. + const items = before.flatMap((b, i) => { + const a = after[i]; + const afterAmount = a?.amount ?? b.amount; + const delta = BigInt(afterAmount) - BigInt(b.amount); + if (delta === 0n) { + return []; + } + return [ + { token: b.token, before: b.amount, after: afterAmount, delta }, + ]; + }); + if (items.length === 0) { + return null; + } + return ( + <> + {items.map((it) => { + const sym = it.token.symbol ?? shortAddress(it.token.address); + return ( +
    + {sym} + + + {formatAmount(it.before, it.token.decimals, 4)} + + + + {formatAmount(it.after, it.token.decimals, 4)} + + + + {it.delta > 0n ? '+' : ''} + {formatAmount(it.delta, it.token.decimals, 4)} + +
    + ); + })} + + ); +} + +function hasBalanceChange(step: Step): boolean { + return step.before.balances.some((b, i) => { + const a = step.after.balances[i]; + return a !== undefined && BigInt(a.amount) !== BigInt(b.amount); + }); +} + +/** Forward-tense pill label for the simulated step — the modal shows what + * *would* happen if the user confirms, so past-tense "executed" reads as + * if it already ran. */ +function stepStatusLabel(status: Step['status']): string { + switch (status) { + case 'executed': + return 'execute'; + default: + return status; + } +} + +function strategyKindLabel(kind: string): string { + switch (kind) { + case 'strategy.dispatch.transfer': + return 'Transfer'; + case 'strategy.dispatch.burn': + return 'Burn'; + case 'strategy.dispatch.epoch-transfer': + return 'EpochTransfer'; + case 'strategy.unknown': + return 'Unknown'; + default: + return kind; + } +} diff --git a/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx b/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx new file mode 100644 index 0000000000..c2e9111bb5 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx @@ -0,0 +1,435 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. + +import { + Background, + Controls, + type Edge, + MiniMap, + type Node, + ReactFlow, +} from '@xyflow/react'; +import { useEffect, useMemo, useState } from 'react'; +import '@xyflow/react/dist/style.css'; +import type { Address } from 'viem'; +import { + type GraphNode, + type TopologyGraph, + toReactFlowGraph, +} from '@/shared/lidoPreview'; +import { formatAmount, shortAddress } from './format'; +import { layout } from './layout'; +import { NodeDetails } from './NodeDetails'; +import type { StatusSnapshot } from './useStatus'; + +// --- Live-value helpers (read from `status` and format for a node label) --- + +function liveBudgetLineForStrategy( + status: StatusSnapshot | undefined, + strategyAddress: Address, +): string | undefined { + if (!status) { + return undefined; + } + const b = status.budgets.find( + (e) => + e.strategyAddress.toLowerCase() === strategyAddress.toLowerCase(), + ); + if (!b) { + return undefined; + } + return `${formatAmount(b.amount, b.token.decimals, 4)} ${b.token.symbol ?? ''}`.trim(); +} + +function liveBudgetLinesForStrategy( + status: StatusSnapshot | undefined, + strategyAddress: Address, +): string[] { + if (!status) { + return []; + } + return status.budgets + .filter( + (e) => + e.strategyAddress.toLowerCase() === + strategyAddress.toLowerCase(), + ) + .map((b) => + `${formatAmount(b.amount, b.token.decimals, 4)} ${b.token.symbol ?? ''}`.trim(), + ); +} + +function liveBudgetLineForBudget( + status: StatusSnapshot | undefined, + budgetAddress: Address, + token: unknown, +): string | undefined { + if (!status) { + return undefined; + } + const b = status.budgets.find( + (e) => e.budgetAddress.toLowerCase() === budgetAddress.toLowerCase(), + ); + if (!b) { + return undefined; + } + const decimals = (token as TokenData)?.decimals ?? null; + return `${formatAmount(b.amount, decimals, 4)}`; +} + +/** Single-line "amount token" for budget nodes — falls back to just the + * token symbol when status hasn't loaded yet, so the second line of a + * budget node always carries useful info. */ +function budgetAmountAndToken( + status: StatusSnapshot | undefined, + budgetAddress: Address, + token: unknown, +): string { + const tok = tokenLabel(token); + const line = liveBudgetLineForBudget(status, budgetAddress, token); + return line ? `${line} ${tok}` : tok; +} + +/** Per-strategy "would fire" pill, sourced from the simulator's prediction. + * Unifies the signals each strategy self-checks (paused state, epoch + * lockout, gate, oracle staleness, budget=0, pool-ratio drift) into one + * short label so the topology node tells the user at a glance whether + * the next dispatch will move tokens here. When the simulator returns + * `no-op`, the predictor's `reason` is appended as a parenthetical so the + * reader doesn't have to click into NodeDetails for the common cases. */ +function strategyStateLabel( + status: StatusSnapshot | undefined, + strategyAddress: Address, +): string | undefined { + const flow = status?.nextDispatch; + if (!flow) { + return undefined; + } + const step = flow.steps.find( + (s) => + s.strategyRef.address.toLowerCase() === + strategyAddress.toLowerCase(), + ); + if (!step) { + return undefined; + } + switch (step.status) { + case 'executed': + return '● active'; + case 'no-op': { + const reason = compactReason(step.reason); + return reason ? `○ closed (${reason})` : '○ closed'; + } + case 'skipped-paused': + return '○ paused'; + case 'opaque': + case 'downstream-opaque': + return '◇ opaque'; + default: + return step.status; + } +} + +/** Shorten predictor `reason` strings so they fit on a topology node. + * The full reason is always available in NodeDetails. */ +function compactReason(reason: string | undefined): string | undefined { + if (!reason) { + return undefined; + } + if (reason.startsWith('oracle stale')) { + return 'oracle stale'; + } + if (reason.startsWith('same epoch')) { + return 'dispatched'; + } + if (reason === 'gate closed') { + return 'gate closed'; + } + if (reason.startsWith('pool ratio')) { + return 'pool drift'; + } + if (reason.includes('budget') && reason.includes('0')) { + return 'budget 0'; + } + if (reason.startsWith('UniV2 pair not deployed')) { + return 'pair missing'; + } + // Fall back to truncated raw reason. + return reason.length > 28 ? `${reason.slice(0, 25)}…` : reason; +} + +export function TopologyView({ + topology, + status, + lidoDaoAddress, + initialSelectedNodeAddress, +}: { + topology: TopologyGraph; + status?: StatusSnapshot; + /** When provided, prepends a "Lido DAO" parent node above the LMM DAO so + * the ownership chain reads top-to-bottom: Lido DAO → LMM DAO → plugin + * → strategies. This is deployment-knowledge from the UI's manifest, + * not something the generic inspect step can derive. */ + lidoDaoAddress?: string; + /** Deep-link target. Matched case-insensitively against `data.address` + * on every node; first match wins and is presented as the open + * NodeDetails sidebar. Used by the dashboard orchestrator chip click + * to land the user directly on the strategy they tapped. */ + initialSelectedNodeAddress?: string; +}) { + const [selected, setSelected] = useState(null); + + const wiring = useMemo(() => { + const raw = toReactFlowGraph(topology); + if (lidoDaoAddress) { + const lidoId = 'lido-dao'; + raw.nodes.push({ + id: lidoId, + type: 'lido-dao', + data: { address: lidoDaoAddress }, + position: { x: 0, y: 0 }, + }); + // The LMM DAO node is emitted as `root/dao` for dispatch-plugin + // topologies (see reactFlow.ts). If we ever generalise this, look + // up the DAO node by type instead of by id. + raw.edges.push({ + id: `${lidoId}->root/dao`, + source: lidoId, + target: 'root/dao', + label: 'owns', + type: 'owns', + }); + } + const laidOut = layout(raw, { rankdir: 'TB' }); + const map = new Map(); + for (const n of laidOut.nodes) { + map.set(n.id, n); + } + return { + nodes: laidOut.nodes.map((n) => toReactFlowNode(n, status)), + edges: laidOut.edges.map(toReactFlowEdge), + byId: map, + }; + }, [topology, status, lidoDaoAddress]); + + const { nodes, edges, byId } = wiring; + + // Close any open details when the topology changes, then re-apply the + // deep-link selection if one was requested. Looked up against + // `data.address` (the only stable identifier we can derive from a + // strategy chip URL — node ids are path-based and not user-meaningful). + // `byId` is a fresh Map per topology re-layout, so depending on it + // implicitly covers topology changes too. + useEffect(() => { + if (initialSelectedNodeAddress == null) { + setSelected(null); + return; + } + const target = initialSelectedNodeAddress.toLowerCase(); + const match = Array.from(byId.values()).find( + (n) => + typeof n.data.address === 'string' && + (n.data.address as string).toLowerCase() === target, + ); + setSelected(match ?? null); + }, [initialSelectedNodeAddress, byId]); + + return ( +
    + { + const original = byId.get(node.id); + if (original) { + setSelected(original); + } + }} + onPaneClick={() => setSelected(null)} + proOptions={{ hideAttribution: true }} + > + + + + + {selected && ( + setSelected(null)} + status={status} + /> + )} +
    + ); +} + +// --- Conversion from our typed GraphNode to React Flow's Node -------------- + +function toReactFlowNode(node: GraphNode, status?: StatusSnapshot): Node { + return { + id: node.id, + type: 'default', + position: node.position, + data: { + label: renderLabel(node, status), + }, + className: cssClass(node.type), + }; +} + +function toReactFlowEdge(edge: { + id: string; + source: string; + target: string; + label?: string; + type?: string; +}): Edge { + return { + id: edge.id, + source: edge.source, + target: edge.target, + label: edge.label, + type: 'default', + animated: edge.type === 'distributes-to', + className: edge.type ? `edge-${edge.type}` : undefined, + }; +} + +// --- Labels ---------------------------------------------------------------- + +function renderLabel(node: GraphNode, status?: StatusSnapshot): string { + const d = node.data; + switch (node.type) { + case 'plugin.dispatch': + // The DAO is now its own parent node, so no need to repeat the + // address here. Just title + strategy count. + return multiline( + 'DispatcherPlugin', + `${d.strategyCount} ${d.strategyCount === 1 ? 'strategy' : 'strategies'}`, + ); + + case 'plugin.unknown': + return multiline( + 'Unknown plugin', + shortAddress(node.id.split('/').pop() ?? ''), + ); + + case 'dao': + return multiline('LMM DAO', shortAddress(d.address as string)); + case 'lido-dao': + return multiline('Lido DAO', shortAddress(d.address as string)); + + // CR vanilla strategies — kept minimal. + case 'strategy.dispatch.transfer': + return multiline('Transfer', d.paused ? 'paused' : undefined); + case 'strategy.dispatch.burn': + return multiline('Burn', d.paused ? 'paused' : undefined); + case 'strategy.dispatch.epoch-transfer': + return multiline('EpochTransfer', d.paused ? 'paused' : undefined); + + // Lido strategies — show live budget on the node when status is loaded. + case 'strategy.dispatch.lido.wrap': + return multiline( + 'Wrap', + liveBudgetLineForStrategy(status, d.address as Address), + strategyStateLabel(status, d.address as Address), + d.paused ? 'paused' : undefined, + ); + case 'strategy.dispatch.lido.univ2-liquidity': + return multiline( + 'UniV2 LP', + ...liveBudgetLinesForStrategy(status, d.address as Address), + strategyStateLabel(status, d.address as Address), + d.paused ? 'paused' : undefined, + ); + case 'strategy.dispatch.lido.gated-cowswap': + return multiline( + 'Gated CowSwap', + liveBudgetLineForStrategy(status, d.address as Address), + strategyStateLabel(status, d.address as Address), + d.paused ? 'paused' : undefined, + ); + case 'strategy.unknown': + return multiline('Unknown strategy', String(d.strategyId ?? '?')); + + case 'budget.full': + return multiline( + 'FullBudget', + budgetAmountAndToken(status, d.address as Address, d.token), + ); + case 'budget.required': + return multiline( + 'RequiredBudget', + budgetAmountAndToken(status, d.address as Address, d.token), + ); + case 'budget.lido.stream-until': + return multiline( + 'StreamUntil', + budgetAmountAndToken(status, d.address as Address, d.token), + ); + case 'budget.unknown': + return multiline('Unknown budget', tokenLabel(d.token)); + + case 'lido.price-floor-gate': { + const g = status?.gate; + // Render whatever's available: live state if status loaded, falls + // back to "PriceFloorGate" alone if not. + return multiline( + 'PriceFloorGate', + g?.price !== null && g?.price !== undefined + ? `${formatAmount(g.price, 6, 2)} USD` + : undefined, + g ? (g.passes ? 'open' : 'closed') : undefined, + ); + } + + case 'splitter.solo': + return multiline('Solo', shortAddress(d.recipient as string)); + case 'splitter.equal': { + const n = (d.recipients as unknown[] | undefined)?.length ?? 0; + return multiline('Equal', `${n} recipient${n === 1 ? '' : 's'}`); + } + case 'splitter.ratio': { + const n = (d.entries as unknown[] | undefined)?.length ?? 0; + return multiline('Ratio', `${n} recipient${n === 1 ? '' : 's'}`); + } + case 'splitter.unknown': + return 'Unknown splitter'; + + case 'epoch-provider': + return multiline('EpochProvider', `epoch ${d.currentEpoch ?? '?'}`); + + case 'recipient': + return shortAddress((d.address as string) ?? ''); + + default: + return node.type; + } +} + +function cssClass(nodeType: string): string { + return `rf-node rf-${nodeType.replace(/\./g, '-')}`; +} + +type TokenData = + | { symbol: string | null; decimals: number | null; address: string } + | undefined; + +function tokenLabel(token: unknown): string { + const t = token as TokenData; + if (!t) { + return '?'; + } + if (t.symbol) { + return t.symbol; + } + return shortAddress(t.address); +} + +function multiline(...lines: Array): string { + return lines.filter((l): l is string => Boolean(l)).join('\n'); +} diff --git a/src/modules/flow/components/lidoMoneyMachine/actions.ts b/src/modules/flow/components/lidoMoneyMachine/actions.ts new file mode 100644 index 0000000000..5b0d4ea2dd --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/actions.ts @@ -0,0 +1,422 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. + +// One-shot write actions the UI can fire against the demo's anvil fork. +// +// All actions broadcast a single tx (or anvil RPC cheat) and return the +// receipt-ish thing. Callers refresh the status snapshot afterwards. +// +// Wiring assumes anvil is running with `--auto-impersonate`, so any address +// can sign without a key — viem just lets anvil sign for us. The deployer +// (anvil[0]) signs normal txs; the whale / Lido Agent are impersonated for +// stETH / LDO transfers respectively. + +import { + type Address, + createWalletClient, + encodeFunctionData, + type Hex, + http, + type PublicClient, + parseAbi, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { mainnet } from 'viem/chains'; +import { + dispatcherPluginAbi, + mockCowSwapSettlementAbi, + mockPriceOracleAbi, +} from '@/shared/lidoPreview'; + +// Aragon DAO's `execute()` reverts the outer tx with `ActionFailed(uint256 +// index)` when one of the strategy actions fails. That error lives on the +// DAO contract, not on the dispatcher plugin we're calling, so viem can't +// decode it from `dispatcherPluginAbi` alone. Merging the error item in +// lets viem pretty-print the failure with the offending action's index, +// which is the only useful clue when chasing a dispatch revert. +const DISPATCH_ERRORS = [ + { + type: 'error', + name: 'ActionFailed', + inputs: [{ name: 'index', type: 'uint256' }], + }, +] as const; +const dispatcherAbiWithErrors = [ + ...dispatcherPluginAbi, + ...DISPATCH_ERRORS, +] as const; + +// anvil[0] private key — same one PrepareDemo + every operator script use. +// Public test key (well-known across foundry/hardhat/anvil tooling); kept +// behind an env override so a future demo can rotate it if the host swaps +// the deployer. The default matches `anvil --auto-impersonate` defaults. +// LMM_DEMO_HACK: this constant is reachable from a production import chain +// (dispatchTransactionDialog → LmmDemoDispatchDialog → here), but the +// dialog is now `next/dynamic` so it never lands in the production bundle +// while LMM_DEMO_MODE=0. +const ANVIL_DEPLOYER_KEY: Hex = (process.env.NEXT_PUBLIC_LMM_DEPLOYER_KEY ?? + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') as Hex; + +const ERC20_TRANSFER_ABI = parseAbi([ + 'function transfer(address,uint256) returns (bool)', +]); + +const ERC20_ALLOWANCE_ABI = parseAbi([ + 'function allowance(address owner, address spender) view returns (uint256)', +]); + +const DAO_EXECUTE_ABI = parseAbi([ + 'function execute(bytes32 callId, (address to, uint256 value, bytes data)[] actions, uint256 allowFailureMap) returns (bytes[], uint256)', +]); + +const STREAM_SET_TARGET_ABI = parseAbi(['function setTargetEpoch(uint64)']); + +const EPOCH_PROVIDER_ABI = parseAbi([ + 'function getEpoch() view returns (uint256)', +]); + +export type ActionContext = { + rpc: string; + publicClient: PublicClient; + dao: Address; + dispatcher: Address; + addresses: { + stETH: Address; + wstETH: Address; + ldo: Address; + weth: Address; + usdc: Address; + mockOracle: Address; + mockCowSwap: Address; + streamBudget: Address; + stEthWhale: Address; + lidoAgent: Address; + epochProvider: Address; + }; +}; + +// Wallet helpers ------------------------------------------------------------ + +function deployerWallet(rpc: string) { + return createWalletClient({ + account: privateKeyToAccount(ANVIL_DEPLOYER_KEY), + chain: mainnet, + transport: http(rpc), + }); +} + +function impersonatedWallet(rpc: string, address: Address) { + // Anvil signs as `address` because the fork is started with --auto-impersonate. + return createWalletClient({ + account: address, + chain: mainnet, + transport: http(rpc), + }); +} + +/** Ensure an impersonated account has enough ETH to pay gas before we + * submit a viem-driven tx from it. Forge's `--unlocked` mode bypasses + * the balance check entirely; `eth_sendTransaction` (what viem uses) + * does not, so contract-address `from`s (Agent, whales) need a topped-up + * ETH balance or the tx fails the entrypoint check with no useful + * revert reason. */ +async function ensureGasFor( + publicClient: PublicClient, + address: Address, +): Promise { + await publicClient.request({ + method: 'anvil_setBalance' as never, + // 100 ETH — plenty for any single tx; idempotent on every call. + params: [address, '0x56bc75e2d63100000'] as never, + }); +} + +// Generic send-and-wait, returning the tx hash. +type WriteArgs = Parameters< + ReturnType['writeContract'] +>[0]; +async function send( + publicClient: PublicClient, + walletClient: + | ReturnType + | ReturnType, + args: WriteArgs, +): Promise { + const hash = await walletClient.writeContract(args); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + // `waitForTransactionReceipt` resolves regardless of tx outcome — we + // have to inspect `status` ourselves. Without this check a reverted + // tx in a multi-step action silently slips through, and a later step + // gets to debug a stale-state symptom (e.g. mock has no LDO because + // the preamble transfer reverted but we proceeded to simulateFill). + if (receipt.status !== 'success') { + throw new Error(`tx ${hash} reverted on-chain (no further reason)`); + } + return hash; +} + +// --- The actions ----------------------------------------------------------- + +export async function dispatchAction(ctx: ActionContext): Promise { + return send(ctx.publicClient, deployerWallet(ctx.rpc), { + address: ctx.dispatcher, + abi: dispatcherAbiWithErrors, + functionName: 'dispatch', + }); +} + +/** Advance the fork's clock by N seconds + mine 1 block. */ +export async function warpAction( + ctx: ActionContext, + seconds: number, +): Promise { + await ctx.publicClient.request({ + method: 'evm_increaseTime' as never, + params: [seconds] as never, + }); + await ctx.publicClient.request({ + method: 'anvil_mine' as never, + params: ['0x1'] as never, + }); +} + +/** Stamp `updatedAt = block.timestamp` on every oracle pair the LMM demo + * reads from. Use after `warpAction` so the staleness check (max age + * 3600s) doesn't trip the UniV2 LP and CowSwap legs — they short-circuit + * to no-op when the oracle hasn't been refreshed since the last warp. + * + * Idempotent: calling it without a preceding warp just bumps `updatedAt` + * to the current head block, which is already the case. Safe to chain + * liberally. Does NOT change any prices — see `setEthPrice` for that. */ +export async function refreshOracle(ctx: ActionContext): Promise { + const wallet = deployerWallet(ctx.rpc); + // Three pairs that the demo's price-floor gate + UniV2 LP read from. + // Each `refresh()` call writes `updatedAt = block.timestamp` for that + // specific (sell, buy) entry — pairs are stored separately, so all of + // them need touching individually. Order doesn't matter. + const pairs: [Address, Address][] = [ + [ctx.addresses.weth, ctx.addresses.usdc], + [ctx.addresses.wstETH, ctx.addresses.ldo], + [ctx.addresses.ldo, ctx.addresses.wstETH], + ]; + for (const [sell, buy] of pairs) { + await send(ctx.publicClient, wallet, { + address: ctx.addresses.mockOracle, + abi: mockPriceOracleAbi, + functionName: 'refresh', + args: [sell, buy], + }); + } +} + +export async function topUpStEth( + ctx: ActionContext, + amount: bigint, +): Promise { + await ensureGasFor(ctx.publicClient, ctx.addresses.stEthWhale); + return send( + ctx.publicClient, + impersonatedWallet(ctx.rpc, ctx.addresses.stEthWhale), + { + address: ctx.addresses.stETH, + abi: ERC20_TRANSFER_ABI, + functionName: 'transfer', + args: [ctx.dao, amount], + }, + ); +} + +export async function topUpLdo( + ctx: ActionContext, + amount: bigint, +): Promise { + await ensureGasFor(ctx.publicClient, ctx.addresses.lidoAgent); + return send( + ctx.publicClient, + impersonatedWallet(ctx.rpc, ctx.addresses.lidoAgent), + { + address: ctx.addresses.ldo, + abi: ERC20_TRANSFER_ABI, + functionName: 'transfer', + args: [ctx.dao, amount], + }, + ); +} + +/** Set the ETH/USD price (whole dollars) + refresh updatedAt on the other + * seeded pairs so the staleness check passes everywhere. + * + * IMPORTANT: anvil's clock drifts away from the host wallclock every time + * we Warp. The gate's staleness check (and ours) reads `block.timestamp`, + * so we must stamp `updatedAt` with that, not `Date.now()`. */ +export async function setEthPrice( + ctx: ActionContext, + usd: number, +): Promise { + const wallet = deployerWallet(ctx.rpc); + const priceScaled = BigInt(usd) * 10n ** 6n; // USDC = 6 decimals + const block = await ctx.publicClient.getBlock(); + const now = block.timestamp; + const hash = await send(ctx.publicClient, wallet, { + address: ctx.addresses.mockOracle, + abi: mockPriceOracleAbi, + functionName: 'setPrice', + args: [ctx.addresses.weth, ctx.addresses.usdc, priceScaled, now], + }); + await send(ctx.publicClient, wallet, { + address: ctx.addresses.mockOracle, + abi: mockPriceOracleAbi, + functionName: 'refresh', + args: [ctx.addresses.wstETH, ctx.addresses.ldo], + }); + await send(ctx.publicClient, wallet, { + address: ctx.addresses.mockOracle, + abi: mockPriceOracleAbi, + functionName: 'refresh', + args: [ctx.addresses.ldo, ctx.addresses.wstETH], + }); + return hash; +} + +/** Push the stream's targetEpoch to `currentEpoch + offset` via a DAO.execute + * call signed by the Lido Agent (only address with EXECUTE on the LMM DAO). */ +export async function setTargetEpoch( + ctx: ActionContext, + offsetEpochs: number, +): Promise { + const currentEpoch = await ctx.publicClient.readContract({ + address: ctx.addresses.epochProvider, + abi: EPOCH_PROVIDER_ABI, + functionName: 'getEpoch', + }); + const newTarget = currentEpoch + BigInt(offsetEpochs); + const inner = encodeFunctionData({ + abi: STREAM_SET_TARGET_ABI, + functionName: 'setTargetEpoch', + args: [newTarget], + }); + + await ensureGasFor(ctx.publicClient, ctx.addresses.lidoAgent); + return send( + ctx.publicClient, + impersonatedWallet(ctx.rpc, ctx.addresses.lidoAgent), + { + address: ctx.dao, + abi: DAO_EXECUTE_ABI, + functionName: 'execute', + args: [ + `0x${'00'.repeat(31)}01` as Hex, // arbitrary callId — only used in events + [ + { + to: ctx.addresses.streamBudget, + value: 0n, + data: inner, + }, + ], + 0n, + ], + }, + ); +} + +/** Simulate a CowSwap solver fill: seed the mock with LDO from the Agent, + * then call simulateFill (pulls wstETH from the DAO via the relayer + * allowance, sends LDO back). */ +export async function settleCowSwap( + ctx: ActionContext, + sellAmount: bigint, + buyAmount: bigint, +): Promise { + await ensureGasFor(ctx.publicClient, ctx.addresses.lidoAgent); + await send( + ctx.publicClient, + impersonatedWallet(ctx.rpc, ctx.addresses.lidoAgent), + { + address: ctx.addresses.ldo, + abi: ERC20_TRANSFER_ABI, + functionName: 'transfer', + args: [ctx.addresses.mockCowSwap, buyAmount], + }, + ); + return send(ctx.publicClient, deployerWallet(ctx.rpc), { + address: ctx.addresses.mockCowSwap, + abi: mockCowSwapSettlementAbi, + functionName: 'simulateFill', + args: [ + ctx.addresses.wstETH, + ctx.addresses.ldo, + ctx.dao, + sellAmount, + buyAmount, + ], + }); +} + +/** Auto-sized variant of `settleCowSwap`: reads the wstETH allowance the + * DAO granted to the mock settlement contract (i.e. the size of the + * *currently presigned* order — the dispatcher rewrites it on every + * dispatch) and fills exactly that. This avoids the brittle + * `parseEther('1')` fixture that fails the moment the stream's + * per-dispatch budget drops below 1 wstETH (typical: ~0.96 after the + * first epoch). `buyAmount` keeps the demo's nominal 1 wstETH ≈ 3000 + * LDO ratio (the mock doesn't enforce minBuyAmount). Returns null when + * there's no pending order so the caller can show a soft message. */ +export async function settleCowSwapAuto( + ctx: ActionContext, +): Promise<{ hash: Hex; sellAmount: bigint; buyAmount: bigint } | null> { + const allowance = (await ctx.publicClient.readContract({ + address: ctx.addresses.wstETH, + abi: ERC20_ALLOWANCE_ABI, + functionName: 'allowance', + args: [ctx.dao, ctx.addresses.mockCowSwap], + })) as bigint; + if (allowance === 0n) { + return null; + } + const buyAmount = allowance * 3000n; + const hash = await settleCowSwap(ctx, allowance, buyAmount); + return { hash, sellAmount: allowance, buyAmount }; +} + +// ---- Manifest → addresses adapter ----------------------------------------- + +export function deriveAddressesFromManifest(m: { + lido?: { + stETH?: string; + wstETH?: string; + ldo?: string; + weth?: string; + usdc?: string; + agent?: string; + }; + cowSwap?: { settlement?: string }; + oracle?: { address?: string }; + lmm?: { budgets?: { wstEthStream?: string }; epochProvider?: string }; + demo?: { stEthWhale?: string }; +}): ActionContext['addresses'] | null { + const required = (a?: string): Address => { + if (!a) { + throw new Error('manifest is missing a required address'); + } + return a as Address; + }; + try { + return { + stETH: required(m.lido?.stETH), + wstETH: required(m.lido?.wstETH), + ldo: required(m.lido?.ldo), + weth: required(m.lido?.weth), + usdc: required(m.lido?.usdc), + mockOracle: required(m.oracle?.address), + mockCowSwap: required(m.cowSwap?.settlement), + streamBudget: required(m.lmm?.budgets?.wstEthStream), + stEthWhale: required(m.demo?.stEthWhale), + lidoAgent: required(m.lido?.agent), + epochProvider: required(m.lmm?.epochProvider), + }; + } catch { + return null; + } +} diff --git a/src/modules/flow/components/lidoMoneyMachine/format.ts b/src/modules/flow/components/lidoMoneyMachine/format.ts new file mode 100644 index 0000000000..c11512a751 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/format.ts @@ -0,0 +1,71 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. + +// Shared formatting helpers for addresses and token amounts. + +export function shortAddress(addr: string | undefined | null): string { + if (!addr || !addr.startsWith('0x') || addr.length < 10) { + return addr ?? ''; + } + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +export function formatAmount( + raw: bigint | string | undefined | null, + decimals: number | null | undefined, + /** When set, truncate the fractional part to this many digits. Values + * smaller than `10^-maxDecimals` get a `< 0.0001` style indicator so + * near-zero "dust" (e.g. 1-wei rebasing leftovers) stays readable + * without taking 18 digits of column width. */ + maxDecimals?: number, +): string { + if (raw === undefined || raw === null) { + return '?'; + } + const asStr = typeof raw === 'bigint' ? raw.toString() : String(raw); + if (!decimals || decimals === 0) { + return withCommas(asStr); + } + return formatWithDecimals(asStr, decimals, maxDecimals); +} + +function formatWithDecimals( + raw: string, + decimals: number, + maxDecimals?: number, +): string { + const isNeg = raw.startsWith('-'); + const digits = isNeg ? raw.slice(1) : raw; + const padded = digits.padStart(decimals + 1, '0'); + const whole = padded.slice(0, padded.length - decimals); + let frac = padded.slice(padded.length - decimals); + // Truncate (not round) — easier to verify against on-chain reads. + if (maxDecimals !== undefined && frac.length > maxDecimals) { + frac = frac.slice(0, maxDecimals); + } + frac = frac.replace(/0+$/, ''); + const sign = isNeg ? '-' : ''; + const wholeFmt = withCommas(whole); + + // Sub-threshold: non-zero amount whose entire visible representation + // would be "0". Surface a hint like `< 0.0001`. + if ( + maxDecimals !== undefined && + digits !== '0' && + whole.replace(/^0+/, '') === '' && + frac.length === 0 + ) { + const threshold = `0.${'0'.repeat(Math.max(0, maxDecimals - 1))}1`; + return `<${sign}${threshold}`; + } + + return frac.length > 0 + ? `${sign}${wholeFmt}.${frac}` + : `${sign}${wholeFmt}`; +} + +function withCommas(digits: string): string { + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} diff --git a/src/modules/flow/components/lidoMoneyMachine/icons.tsx b/src/modules/flow/components/lidoMoneyMachine/icons.tsx new file mode 100644 index 0000000000..9b764645c5 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/icons.tsx @@ -0,0 +1,71 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. + +// Minimal SVG icons. No external icon library — the UI dependency policy +// is "only what's strictly needed", and these are three shapes. + +export const InspectIcon = () => ( + +); + +export const SimulateIcon = () => ( + +); + +export const StatusIcon = () => ( + +); + +export const CloseIcon = () => ( + +); diff --git a/src/modules/flow/components/lidoMoneyMachine/layout.ts b/src/modules/flow/components/lidoMoneyMachine/layout.ts new file mode 100644 index 0000000000..7d095bb8c2 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/layout.ts @@ -0,0 +1,164 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. +// +// Local extension: `rankdir` argument so the same dagre helper handles both +// the structural wiring view (TB) and the money-flow view (LR). See +// `TopologyView` for the toggle that selects between the two. + +import dagre from '@dagrejs/dagre'; +import type { ReactFlowGraph } from '@/shared/lidoPreview'; + +const NODE_WIDTH = 200; +const NODE_HEIGHT = 72; + +export interface ILayoutOptions { + /** TB = top-to-bottom (wiring), LR = left-to-right (money flow). */ + rankdir?: 'TB' | 'LR'; + nodesep?: number; + ranksep?: number; +} + +/** + * dagre layout. React Flow's default node handles are at top/bottom edges + * for TB and left/right for LR, so the same node component works in both + * orientations without custom handles. + * + * React Flow positions by top-left; dagre by node center — we subtract + * half the size to align. + */ +export function layout( + graph: ReactFlowGraph, + options: ILayoutOptions = {}, +): ReactFlowGraph { + const { rankdir = 'TB', nodesep = 40, ranksep = 100 } = options; + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + // `tight-tree` ranker keeps strategies in array-index order on the + // same rank. Default `network-simplex` minimizes crossings via + // barycenter sweeps, which is free to mirror the strategy row + // (Wrap → UniV2 → CowSwap arrived as CowSwap → UniV2 → Wrap on some + // layouts). `tight-tree` is deterministic for tree-shaped inputs. + g.setGraph({ rankdir, nodesep, ranksep, ranker: 'tight-tree' }); + + for (const node of graph.nodes) { + g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + } + for (const edge of graph.edges) { + g.setEdge(edge.source, edge.target); + } + + dagre.layout(g); + + // ---- In-rank ordering post-pass --------------------------------------- + // + // `ranker: 'tight-tree'` only fixes rank *assignment*; dagre's order + // phase still uses barycenter to minimise crossings, which is free to + // mirror a sibling row. In the wiring view the user expects every + // node to sit under the strategy that uses it, ordered left→right by + // on-chain index (`composes [0]`, `[1]`, `[2]` — emitted by + // `toReactFlowGraph` as edge labels). + // + // Two-phase fix: + // 1. Read `[N]` labels off every edge → that pins strategies. + // 2. Propagate orders downstream along outgoing edges (min over all + // ordered ancestors) so budgets/gates/providers inherit their + // parent strategy's column. A node reached by both `[0]` and + // `[2]` ends up under `[0]` — intentional: shared infra hugs the + // leftmost user. + // Then we rebuild the cross-axis coordinate per rank by sorting all + // ordered nodes, preserving the rank's existing extent so the layout + // doesn't visually jump compared to dagre's default. + const horizontal = rankdir === 'LR'; + const orderByNode = new Map(); + for (const e of graph.edges) { + const m = (e.label ?? '').match(/^\[(\d+)\]$/); + if (m) { + const n = Number(m[1]); + const cur = orderByNode.get(e.target); + if (cur == null || n < cur) { + orderByNode.set(e.target, n); + } + } + } + + // BFS-style propagation: keep relaxing min(order) along edges until no + // more updates. Bounded by node count so we can't loop on a pathological + // cycle (graph is a DAG by construction, but defence in depth is cheap). + let safety = graph.nodes.length + 1; + let changed = true; + while (changed && safety > 0) { + safety -= 1; + changed = false; + for (const e of graph.edges) { + const src = orderByNode.get(e.source); + if (src == null) { + continue; + } + const tgt = orderByNode.get(e.target); + if (tgt == null || src < tgt) { + orderByNode.set(e.target, src); + changed = true; + } + } + } + + if (orderByNode.size > 0) { + const buckets = new Map< + number, + Array<{ id: string; cross: number; order: number }> + >(); + for (const n of graph.nodes) { + const order = orderByNode.get(n.id); + if (order == null) { + continue; + } + const pos = g.node(n.id); + const rank = Math.round(horizontal ? pos.x : pos.y); + const cross = horizontal ? pos.y : pos.x; + const list = buckets.get(rank) ?? []; + list.push({ id: n.id, cross, order }); + buckets.set(rank, list); + } + for (const list of buckets.values()) { + if (list.length < 2) { + continue; + } + const crosses = list + .map((entry) => entry.cross) + .sort((a, b) => a - b); + // Stable order: primary by inherited strategy index, secondary by + // dagre's original cross-axis position so siblings within the same + // order bucket (e.g. two budgets for the same leg) keep dagre's + // crossing-minimising arrangement. + list.sort((a, b) => a.order - b.order || a.cross - b.cross); + list.forEach((entry, i) => { + const target = crosses[i]; + if (target == null) { + return; + } + const dagreNode = g.node(entry.id); + if (horizontal) { + dagreNode.y = target; + } else { + dagreNode.x = target; + } + }); + } + } + + return { + nodes: graph.nodes.map((n) => { + const pos = g.node(n.id); + return { + ...n, + position: { + x: pos.x - NODE_WIDTH / 2, + y: pos.y - NODE_HEIGHT / 2, + }, + }; + }), + edges: graph.edges, + }; +} diff --git a/src/modules/flow/components/lidoMoneyMachine/styles.css b/src/modules/flow/components/lidoMoneyMachine/styles.css new file mode 100644 index 0000000000..a89ab73b3a --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/styles.css @@ -0,0 +1,1246 @@ +/* Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +*/ +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + padding: 0; + height: 100%; + font-family: + system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; + color: #0f172a; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; +} + +header { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 12px 16px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + align-items: center; +} + +header label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +header input { + padding: 6px 10px; + border: 1px solid #cbd5e1; + border-radius: 4px; + font-size: 13px; + font-family: ui-monospace, "SF Mono", "Menlo", monospace; + background: white; + color: #0f172a; +} + +header input:focus { + outline: 2px solid #3b82f6; + outline-offset: -1px; + border-color: #3b82f6; +} + +header label:nth-of-type(1) .combo-input { + width: 360px; +} +header label:nth-of-type(2) input { + width: 280px; + font-size: 12px; +} + +/* ---- Combobox ------------------------------------------------------------ */ + +.combo { + position: relative; + display: inline-flex; +} + +.combo-input { + padding-right: 28px !important; +} + +.combo-arrow { + position: absolute; + right: 2px; + top: 50%; + transform: translateY(-50%); + background: transparent !important; + color: #64748b !important; + padding: 4px 8px !important; + font-size: 11px !important; + font-weight: normal !important; + cursor: pointer; + border-radius: 2px !important; +} +.combo-arrow:hover { + background: #f1f5f9 !important; + color: #0f172a !important; +} + +.combo-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + margin: 0; + padding: 4px; + list-style: none; + background: white; + border: 1px solid #cbd5e1; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1); + z-index: 100; + max-height: 320px; + overflow-y: auto; +} + +.combo-menu li { + padding: 7px 10px; + cursor: pointer; + border-radius: 3px; + display: flex; + flex-direction: column; + gap: 2px; +} +.combo-menu li:hover { + background: #eff6ff; +} +.combo-menu li[aria-selected="true"] { + background: #dbeafe; +} + +.combo-label { + font-size: 12px; + color: #0f172a; + font-weight: 500; +} + +.combo-addr { + font-size: 11px; + font-family: ui-monospace, monospace; + color: #64748b; +} + +header button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + background: #2563eb; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + font-weight: 500; +} +header button:hover:not(:disabled) { + background: #1d4ed8; +} +header button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +header button.secondary { + background: white; + color: #2563eb; + border: 1px solid #2563eb; +} +header button.secondary:hover:not(:disabled) { + background: #eff6ff; + color: #1d4ed8; +} + +.error { + padding: 12px 16px; + background: #fef2f2; + color: #991b1b; + border-bottom: 1px solid #fee2e2; + font-family: ui-monospace, monospace; + font-size: 13px; + white-space: pre-wrap; +} + +.hint { + padding: 40px; + text-align: center; + color: #94a3b8; + font-size: 14px; +} + +.main { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.topology { + flex: 1; + min-height: 0; + position: relative; +} + +/* React Flow's default handles are drag-to-connect anchors we don't use — + * edges still render at those positions, but the handles themselves should + * be invisible and non-interactive. */ +.topology .react-flow__handle { + opacity: 0; + pointer-events: none; +} + +/* ---- Node details side panel --------------------------------------------- */ + +.panel { + position: absolute; + top: 12px; + right: 12px; + width: 340px; + max-height: calc(100% - 24px); + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 50; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} +.panel-header h3 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: #0f172a; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.panel-close { + background: transparent; + border: none; + padding: 4px; + cursor: pointer; + color: #64748b; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; +} +.panel-close:hover { + background: rgba(0, 0, 0, 0.05); + color: #0f172a; +} + +.panel-fields { + margin: 0; + padding: 12px 14px; + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 14px; + overflow-y: auto; + font-size: 12px; +} +.panel-fields dt { + color: #64748b; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; + padding-top: 2px; +} +.panel-fields dd { + margin: 0; + color: #0f172a; + overflow-wrap: anywhere; +} + +.panel-mono { + font-family: ui-monospace, monospace; + font-size: 11px; + color: #334155; + background: rgba(0, 0, 0, 0.03); + padding: 2px 5px; + border-radius: 3px; +} + +.panel-addr-wrap { + position: relative; + display: inline-block; +} + +.panel-addr { + background: transparent; + border: 1px solid transparent; + padding: 1px 5px; + border-radius: 3px; + cursor: pointer; + color: #2563eb; + display: inline-flex; +} +.panel-addr:hover { + background: #eff6ff; + border-color: #bfdbfe; +} +.panel-addr code { + font-family: ui-monospace, monospace; + font-size: 11px; + background: transparent; +} + +/* Copy confirmation tooltip (appears below the address on click) */ +.panel-addr-toast { + position: absolute; + top: calc(100% + 4px); + left: 0; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 9px; + background: #0f172a; + color: white; + font-size: 10px; + border-radius: 4px; + white-space: nowrap; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18); + z-index: 200; + pointer-events: none; + animation: addr-toast-in 140ms ease-out; +} +.panel-addr-toast::before { + content: ""; + position: absolute; + top: -4px; + left: 10px; + border: 4px solid transparent; + border-bottom-color: #0f172a; +} +.panel-addr-toast-label { + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; + color: #4ade80; +} +.panel-addr-toast code { + font-family: ui-monospace, monospace; + font-size: 10px; + color: #e2e8f0; + background: transparent; + padding: 0; +} + +@keyframes addr-toast-in { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.panel-na { + color: #94a3b8; +} + +.panel-subtle { + color: #94a3b8; +} + +.panel-list { + grid-column: 1 / -1; + list-style: none; + margin: 4px 0 0; + padding: 0; + border: 1px solid #e2e8f0; + border-radius: 4px; + overflow: hidden; +} +.panel-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 10px; + font-size: 11px; +} +.panel-list li + li { + border-top: 1px solid #f1f5f9; +} +.panel-list li:nth-child(odd) { + background: #f8fafc; +} + +.panel-ratio { + font-family: ui-monospace, monospace; + color: #475569; + font-size: 11px; +} + +/* ---- Flow panel (step timeline at bottom) -------------------------------- */ + +.flow { + border-top: 1px solid #e2e8f0; + background: #ffffff; + display: flex; + flex-direction: column; + position: relative; + flex-shrink: 0; +} + +.flow-resize { + height: 6px; + cursor: ns-resize; + background: transparent; + border-top: 1px solid transparent; + position: relative; +} +.flow-resize::before { + content: ""; + position: absolute; + left: 50%; + top: 2px; + transform: translateX(-50%); + width: 32px; + height: 2px; + border-radius: 1px; + background: #cbd5e1; + transition: background 120ms ease; +} +.flow-resize:hover::before, +.flow-resize-active::before { + background: #2563eb; + width: 48px; +} + +.flow-header { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 12px; + color: #475569; +} + +.flow-close { + margin-left: auto; + background: transparent; + border: none; + padding: 4px; + cursor: pointer; + color: #64748b; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; +} +.flow-close:hover { + background: rgba(0, 0, 0, 0.05); + color: #0f172a; +} +.flow-header strong { + color: #0f172a; + font-size: 13px; +} +.flow-meta { + color: #64748b; + font-family: ui-monospace, monospace; +} +.flow-warnings { + padding: 2px 8px; + background: #fef3c7; + color: #92400e; + border-radius: 9999px; + font-weight: 500; +} + +.flow-tabs { + margin-left: auto; + display: inline-flex; + border: 1px solid #cbd5e1; + border-radius: 4px; + overflow: hidden; + background: white; +} +.flow-tabs button { + padding: 4px 12px; + background: white; + color: #475569; + border: none; + cursor: pointer; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; +} +.flow-tabs button + button { + border-left: 1px solid #cbd5e1; +} +.flow-tabs button:hover:not(.active) { + background: #f1f5f9; + color: #0f172a; +} +.flow-tabs button.active { + background: #2563eb; + color: white; +} + +.flow-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.flow-steps { + display: flex; + gap: 10px; + padding: 12px 16px; + overflow-x: auto; + overflow-y: hidden; + flex: 1; +} + +/* ---- Flow graph view ----------------------------------------------------- */ + +.flow-graph { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.flow-graph-controls { + display: flex; + gap: 14px; + align-items: center; + padding: 8px 16px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 12px; + color: #475569; +} + +.flow-graph-slider { + flex: 1; + max-width: 480px; + accent-color: #2563eb; +} + +.flow-graph-step-label { + font-family: ui-monospace, monospace; + color: #0f172a; + min-width: 180px; +} + +.flow-graph-canvas { + flex: 1; + min-height: 0; + position: relative; + background: white; +} + +.flow-graph-canvas .react-flow__handle { + opacity: 0; + pointer-events: none; +} + +/* Participant nodes */ +.rf-node.react-flow__node-default.fg-dao, +.rf-node.react-flow__node-default.fg-recipient, +.rf-node.react-flow__node-default.fg-burn, +.rf-node.react-flow__node-default.fg-external { + white-space: pre-line !important; + font-size: 11px; + text-align: center; + padding: 8px 10px; + border-radius: 6px; + border-width: 2px; + min-width: 140px; +} + +.fg-dao { + background: #eff6ff !important; + border-color: #3b82f6 !important; + color: #1e3a8a !important; + font-weight: 600; +} +.fg-recipient { + background: #f1f5f9 !important; + border-color: #64748b !important; + color: #1e293b !important; + font-family: ui-monospace, monospace !important; +} +.fg-burn { + background: #fff7ed !important; + border-color: #f97316 !important; + color: #7c2d12 !important; +} +.fg-external { + background: #f1f5f9 !important; + border-color: #94a3b8 !important; + color: #334155 !important; + border-style: dashed !important; +} + +/* Edges */ +.fg-edge-executed .react-flow__edge-path { + stroke: #2563eb; + stroke-width: 1.5; +} +.fg-edge-opaque .react-flow__edge-path, +.fg-edge-downstream-opaque .react-flow__edge-path { + stroke: #f97316; + stroke-width: 1.5; + stroke-dasharray: 5 3; +} +.fg-edge-verb-burn .react-flow__edge-path { + stroke: #b45309; +} + +.step { + flex-shrink: 0; + width: 280px; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: white; + display: flex; + flex-direction: column; + font-size: 12px; + overflow: hidden; +} +.step-executed { + border-color: #86efac; + background: #f0fdf4; +} +.step-no-op { + border-color: #cbd5e1; + background: #f8fafc; + color: #64748b; +} +.step-skipped-paused { + border-color: #fcd34d; + background: #fffbeb; +} +.step-opaque, +.step-downstream-opaque { + border-color: #fdba74; + background: #fff7ed; + border-style: dashed; +} + +.step-head { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + background: rgba(255, 255, 255, 0.5); +} +.step-index { + font-family: ui-monospace, monospace; + color: #64748b; + font-size: 11px; +} +.step-kind { + font-weight: 600; + color: #0f172a; + flex: 1; +} +.step-status { + padding: 1px 8px; + border-radius: 9999px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + font-weight: 600; +} +.step-status-executed { + background: #22c55e; + color: white; +} +.step-status-no-op { + background: #cbd5e1; + color: #334155; +} +.step-status-skipped-paused { + background: #f59e0b; + color: white; +} +.step-status-opaque, +.step-status-downstream-opaque { + background: #f97316; + color: white; +} + +.step-reason { + padding: 6px 12px; + background: rgba(0, 0, 0, 0.03); + color: #475569; + font-style: italic; + font-size: 11px; +} + +.step-empty { + padding: 10px 14px; + color: #94a3b8; + font-style: italic; + font-size: 11px; +} + +/* Flow diagram inside a step card (DAO → amount → recipient[]) */ + +.step-flow { + padding: 12px 14px 6px; +} + +.flow-source { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 4px; +} + +.flow-node { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + color: #0f172a; + font-size: 12px; +} + +.flow-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.flow-amount-out { + font-family: ui-monospace, monospace; + font-size: 11px; + color: #0f172a; + font-weight: 500; +} + +.flow-branches { + list-style: none; + padding: 0 0 0 4px; + margin: 0; + position: relative; +} + +.flow-branches li { + position: relative; + padding: 4px 0 4px 22px; + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; +} + +/* Vertical trunk down the left of the branch list */ +.flow-branches li::before { + content: ""; + position: absolute; + left: 4px; + top: 0; + bottom: 0; + width: 1px; + background: #cbd5e1; +} + +/* Horizontal tee pointing at each branch */ +.flow-branches li::after { + content: ""; + position: absolute; + left: 4px; + top: 50%; + width: 14px; + height: 1px; + background: #cbd5e1; +} + +/* Last branch: trunk stops at the tee, making an L-corner */ +.flow-branches li:last-child::before { + bottom: 50%; +} + +.flow-amount { + font-family: ui-monospace, monospace; + color: #0f172a; + font-weight: 500; + white-space: nowrap; +} + +.flow-target { + font-family: ui-monospace, monospace; + color: #475569; + white-space: nowrap; + flex: 1; + text-align: right; +} + +.flow-target-burn { + color: #b45309; + font-weight: 500; + text-transform: lowercase; +} + +.flow-target-call { + color: #475569; + font-style: italic; +} + +/* Produce branch (e.g. wrap → +wstETH). Same shape as a call but with a + gain-coloured amount so it reads symmetrically with a token transfer. */ +.flow-target-produce { + color: #047857; + font-weight: 500; + text-transform: lowercase; +} +li:has(.flow-target-produce) .flow-amount { + color: #047857; +} + +.step-delta { + padding: 8px 12px; + border-top: 1px solid rgba(0, 0, 0, 0.06); + background: rgba(0, 0, 0, 0.02); +} +.delta { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + font-family: ui-monospace, monospace; + font-size: 11px; + align-items: center; +} +.delta-sym { + color: #475569; + font-weight: 600; +} +.delta-values { + color: #64748b; +} +.delta-arrow { + margin: 0 4px; + color: #94a3b8; +} +.delta-minus { + color: #b91c1c; +} +.delta-plus { + color: #15803d; +} + +.sim-error { + border-top: 1px solid #fee2e2; + border-bottom: 0; +} + +/* React Flow node variants, keyed by the kind class emitted by TopologyView. */ +.react-flow__node .react-flow__node-default { + white-space: pre-line; +} + +.rf-node.react-flow__node-default { + white-space: pre-line !important; + font-size: 12px; + text-align: center; + padding: 10px 12px; + border-radius: 6px; + border-width: 2px; + min-width: 160px; +} + +.rf-plugin-dispatch { + background: #eff6ff !important; + border-color: #3b82f6 !important; + color: #1e3a8a !important; + font-weight: 600; +} +.rf-strategy-dispatch-transfer, +.rf-strategy-dispatch-burn, +.rf-strategy-dispatch-epoch-transfer { + background: #f0fdf4 !important; + border-color: #22c55e !important; + color: #14532d !important; + font-weight: 500; +} +/* Lido-specific strategies — same family palette as CR strategies but + shifted to teal so the user can spot them at a glance. */ +.rf-strategy-dispatch-lido-wrap, +.rf-strategy-dispatch-lido-univ2-liquidity, +.rf-strategy-dispatch-lido-gated-cowswap { + background: #ecfeff !important; + border-color: #06b6d4 !important; + color: #164e63 !important; + font-weight: 500; +} +.rf-budget-full, +.rf-budget-required, +.rf-budget-stream, +.rf-budget-lido-stream-until { + background: #fefce8 !important; + border-color: #eab308 !important; + color: #713f12 !important; +} +/* Lido PriceFloorGate — pink so the soft-gate concept stands out from + the per-strategy "epoch lockout" boolean. */ +.rf-lido-price-floor-gate { + background: #fdf2f8 !important; + border-color: #ec4899 !important; + color: #831843 !important; + font-weight: 500; +} +.rf-splitter-solo, +.rf-splitter-equal, +.rf-splitter-ratio { + background: #faf5ff !important; + border-color: #a855f7 !important; + color: #581c87 !important; +} +.rf-recipient { + background: #f1f5f9 !important; + border-color: #64748b !important; + color: #1e293b !important; + font-family: ui-monospace, monospace !important; + font-size: 11px !important; +} +/* DAO sits at the top of the wiring as the plugin's owner. Slate, slightly + heavier border so it reads as the root visually. */ +.rf-dao { + background: #f1f5f9 !important; + border-color: #1e293b !important; + color: #0f172a !important; + font-weight: 600 !important; + border-width: 2px !important; +} +/* Lido DAO is the externally-owned root above the LMM DAO — same visual + weight as the LMM DAO but with the Lido brand tint so the ownership + chain reads at a glance. */ +.rf-lido-dao { + background: #ecfeff !important; + border-color: #0891b2 !important; + color: #083344 !important; + font-weight: 600 !important; + border-width: 2px !important; +} +.rf-epoch-provider { + background: #fff7ed !important; + border-color: #f97316 !important; + color: #7c2d12 !important; +} +.rf-plugin-unknown, +.rf-strategy-unknown, +.rf-budget-unknown, +.rf-splitter-unknown { + background: #f1f5f9 !important; + border-color: #94a3b8 !important; + color: #334155 !important; + border-style: dashed !important; +} + +/* Edges */ +.react-flow__edge-path { + stroke-width: 1.5; +} + +/* --- BottomPanel outer-mode tabs ----------------------------------------- */ +.bp-mode-tabs { + display: flex; + gap: 4px; + margin-right: 12px; +} +.bp-mode-tabs button { + background: transparent; + border: 1px solid transparent; + border-bottom-color: #cbd5e1; + border-radius: 6px 6px 0 0; + padding: 4px 10px; + font-size: 13px; + font-weight: 500; + color: #64748b; + cursor: pointer; +} +.bp-mode-tabs button:hover:not(:disabled):not(.active) { + color: #1e293b; +} +.bp-mode-tabs button.active { + background: #fff; + border-color: #cbd5e1; + border-bottom-color: transparent; + color: #0f172a; +} +.bp-mode-tabs button:disabled { + color: #cbd5e1; + cursor: not-allowed; +} + +/* --- Status view --------------------------------------------------------- + * + * The old `.status-card` / `.status-grid` rules have been removed — the + * Aragon-styled `StatusView` now uses gov-ui-kit `Card` + `Accordion` + * + `DefinitionList` and renders entirely via Tailwind classes. See + * StatusView.tsx. Any rules below are kept only because they still + * appear in non-status surfaces (e.g. the dispatch dialog). + */ +.edge-distributes-to .react-flow__edge-path { + stroke: #a855f7; +} + +/* -- Header: primary Dispatch button + transient feedback line ----------- */ + +header button.primary { + background: #2563eb; + color: white; + font-weight: 600; +} +header button.primary:hover:not(:disabled) { + background: #1d4ed8; +} + +/* -- Floating action-feedback toast ------------------------------------- */ + +.toast { + position: fixed; + top: 16px; + right: 16px; + z-index: 200; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px 10px 14px; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); + font-family: ui-monospace, monospace; + font-size: 13px; + max-width: 420px; + animation: toast-in 160ms ease-out; +} +@keyframes toast-in { + from { + transform: translateY(-8px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} +.toast-ok { + background: #ecfdf5; + color: #065f46; + border: 1px solid #a7f3d0; +} +.toast-err { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} +.toast-message { + flex: 1; + white-space: pre-wrap; + word-break: break-word; +} +.toast-dismiss { + background: transparent; + border: none; + color: inherit; + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + opacity: 0.6; +} +.toast-dismiss:hover { + opacity: 1; +} + +/* -- "More actions ▾" dropdown ------------------------------------------ */ + +.actions-menu { + position: relative; + display: inline-block; +} +.actions-menu-list { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 50; + padding: 4px; + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + min-width: 300px; +} +.actions-menu-group { + list-style: none; + margin: 0; + padding: 0; +} +.actions-menu-group + .actions-menu-group { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid #e5e7eb; +} +.actions-menu-group li { + display: block; +} +.actions-menu-list button { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + padding: 8px 12px; + background: transparent; + border: none; + color: #111827; + font-size: 13px; + text-align: left; + border-radius: 6px; + cursor: pointer; +} +.actions-menu-list button:hover:not(:disabled) { + background: #f3f4f6; +} +.actions-menu-list button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.actions-menu-label { + font-weight: 500; +} +.actions-menu-desc { + font-size: 11px; + color: #6b7280; + margin-top: 2px; +} +.actions-menu-busy { + margin-left: auto; + color: #6b7280; +} + +/* -- Status-panel: refresh indicator ------------------------------------ */ + +.status-refreshing { + color: #6b7280; + font-style: italic; +} +.status-refresh-error { + color: #b91c1c; + cursor: help; +} + +/* -- Dispatch confirmation modal ---------------------------------------- */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 24px; +} +.modal { + background: white; + border-radius: 12px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); + width: min(640px, 100%); + max-height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid #e5e7eb; +} +.modal-header h3 { + margin: 0; + font-size: 15px; + font-weight: 600; +} +.modal-body { + padding: 16px 18px; + overflow: auto; + flex: 1; + min-height: 0; +} +.modal-steps { + /* StepsView ships its own padding; we only need to host it. */ + margin: 0 -4px; +} +.modal-footer { + border-top: 1px solid #e5e7eb; + padding: 12px 18px; + display: flex; + flex-direction: column; + gap: 8px; +} +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.modal-actions button { + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + border: 1px solid transparent; + background: #2563eb; + color: white; + cursor: pointer; +} +.modal-actions button:hover:not(:disabled) { + background: #1d4ed8; +} +.modal-actions button.secondary { + background: white; + color: #2563eb; + border-color: #2563eb; +} +.modal-actions button.secondary:hover:not(:disabled) { + background: #eff6ff; +} +.modal-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/modules/flow/components/lidoMoneyMachine/useStatus.ts b/src/modules/flow/components/lidoMoneyMachine/useStatus.ts new file mode 100644 index 0000000000..e7730b506d --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/useStatus.ts @@ -0,0 +1,507 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/ui/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring +// of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. + +// Live-status fetcher. Given a fresh PublicClient + the inspected +// TopologyGraph, reads every "what does the DAO actually hold and what would +// the next dispatch do?" datum from chain. Returns a typed snapshot the +// StatusView can render without re-walking the topology. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { type Address, type PublicClient, parseAbi, zeroAddress } from 'viem'; +import { + type BudgetNode, + type FlowGraph, + type StrategyNode, + simulate, + type TokenInfo, + type TopologyGraph, +} from '@/shared/lidoPreview'; + +// Mainnet V2 factory (same address on forks). Used to look up the LP pair +// that the UniV2 strategy adds liquidity into. +const UNIV2_FACTORY: Address = '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f'; + +const erc20Abi = parseAbi([ + 'function balanceOf(address) view returns (uint256)', + 'function allowance(address,address) view returns (uint256)', + 'function totalSupply() view returns (uint256)', +]); +const budgetAbi = parseAbi(['function budget() view returns (uint256)']); +const epochProviderAbi = parseAbi([ + 'function getEpoch() view returns (uint256)', +]); +const gateAbi = parseAbi(['function passes() view returns (bool)']); +const oraclePriceAbi = parseAbi([ + 'function getPrice(address,address) view returns (uint256,uint256)', +]); +const factoryAbi = parseAbi([ + 'function getPair(address,address) view returns (address)', +]); +const mockSettlementAbi = parseAbi([ + 'function ordersPlaced() view returns (uint256)', +]); + +export type StatusSnapshot = { + block: bigint; + /** Anvil's block.timestamp at fetch time. Authoritative for any + * on-chain "is this stale?" check — JS wallclock can drift arbitrarily + * far from anvil after Warp actions. */ + blockTimestamp: bigint; + fetchedAt: number; + dao: Address; + /** DAO token holdings — every distinct token referenced by any budget. */ + balances: { token: TokenInfo; amount: bigint }[]; + /** Per-strategy `budget()` reading. */ + budgets: { + strategyAddress: Address; + strategyKind: StrategyNode['kind']; + label: string; + /** Reading came from this budget node. */ + budgetAddress: Address; + budgetKind: BudgetNode['kind']; + token: TokenInfo; + amount: bigint; + }[]; + /** PriceFloorGate (if any GatedCowSwap is in the topology). */ + gate?: { + address: Address; + passes: boolean; + tokenA: Address; + tokenB: Address; + threshold: bigint; + /** Latest oracle reading. `null` if the oracle call reverted. */ + price: bigint | null; + updatedAt: bigint | null; + maxStaleness: bigint; + }; + /** CowSwap orders + DAO's wstETH allowance to the (mock) settlement. */ + cowSwap?: { + settlement: Address; + ordersPlaced: bigint | null; + allowance: bigint; + allowanceToken: TokenInfo; + }; + /** StreamUntil budget config + computed remaining. */ + stream?: { + budgetAddress: Address; + token: TokenInfo; + targetEpoch: bigint; + floorEpochs: number; + currentEpoch: bigint; + remaining: bigint; + }; + /** UniV2 LP pair + recipient's LP balance. `pair === null` means the + * pair hasn't been deployed yet (no factory entry). */ + lp?: { + pair: Address | null; + recipientBalance: bigint; + }; + /** What `simulate()` predicts the next `dispatch()` will actually do. + * `null` when the simulator itself failed (e.g. a predictor reverted). + * Consumed by the Dispatch-confirmation modal — not surfaced as a card. */ + nextDispatch: FlowGraph | null; + nextDispatchError?: string; +}; + +export type StatusState = + | { kind: 'idle' } + | { kind: 'loading' } + // `refreshing` distinguishes a polled re-read (snapshot is "live") from + // the first read after Inspect (snapshot didn't exist before). The + // StatusView keeps rendering the prior snapshot while a refresh is in + // flight so the user doesn't see a flash to "Loading…". `refreshError` + // is set when an in-flight refresh fails but we already had data — we + // keep the stale snapshot visible and surface the error inline. + | { + kind: 'ready'; + snapshot: StatusSnapshot; + refreshing: boolean; + refreshError?: string; + } + | { kind: 'error'; message: string }; + +/** + * Resolves the StatusSnapshot for the current topology. Re-runs whenever + * `topology` changes; call `refresh()` to re-fetch against the current chain + * head. + */ +export function useStatus( + clientFactory: () => PublicClient, + topology: TopologyGraph | null, + /** Polling cadence in ms. 0 = no polling (refresh only on manual call). */ + pollMs = 5000, +): { state: StatusState; refresh: () => void } { + const [state, setState] = useState({ kind: 'idle' }); + // Mirror `state` into a ref so the `fetch` callback (memoized on + // [clientFactory, topology]) can read the *current* state at call time + // without invalidating itself on every state transition. + const stateRef = useRef({ kind: 'idle' }); + stateRef.current = state; + + const fetch = useCallback(async () => { + if (!topology || topology.root.kind !== 'plugin.dispatch') { + setState({ kind: 'idle' }); + return; + } + // If we already have a snapshot, do a quiet refresh: keep the prior + // snapshot rendered, only flip the `refreshing` flag. + const prior = stateRef.current; + if (prior.kind === 'ready') { + setState({ ...prior, refreshing: true, refreshError: undefined }); + } else { + setState({ kind: 'loading' }); + } + try { + const client = clientFactory(); + // Snapshot reads + simulate() in parallel. simulate() can throw (a + // predictor revert) — surface it as a `nextDispatchError` rather than + // aborting the whole snapshot, since the live balances/budgets/gate + // are still useful even when the next-dispatch preview fails. + const [snap, simResult] = await Promise.all([ + fetchSnapshot(client, topology), + simulate(client, topology).then( + (flow) => ({ ok: true as const, flow }), + (err: unknown) => ({ + ok: false as const, + error: err instanceof Error ? err.message : String(err), + }), + ), + ]); + const final: StatusSnapshot = simResult.ok + ? { ...snap, nextDispatch: simResult.flow } + : { + ...snap, + nextDispatch: null, + nextDispatchError: simResult.error, + }; + setState({ kind: 'ready', snapshot: final, refreshing: false }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + // Don't blow away a known-good snapshot on a transient refresh + // failure — keep showing the prior values and surface the error as a + // banner. Only fall back to the hard-error state if we have nothing. + if (prior.kind === 'ready') { + setState({ + kind: 'ready', + snapshot: prior.snapshot, + refreshing: false, + refreshError: message, + }); + } else { + setState({ kind: 'error', message }); + } + } + }, [clientFactory, topology]); + + useEffect(() => { + void fetch(); + }, [fetch]); + + // Periodic refresh. Pauses while the tab is hidden so we don't burn RPC + // when the user is away. + useEffect(() => { + if (pollMs <= 0 || !topology) { + return; + } + let cancelled = false; + const tick = () => { + if (!cancelled && !document.hidden) { + void fetch(); + } + }; + const handle = window.setInterval(tick, pollMs); + const onVis = () => { + // Snap to a fresh read the moment we come back. + if (!document.hidden) { + tick(); + } + }; + document.addEventListener('visibilitychange', onVis); + return () => { + cancelled = true; + window.clearInterval(handle); + document.removeEventListener('visibilitychange', onVis); + }; + }, [fetch, pollMs, topology]); + + return { state, refresh: () => void fetch() }; +} + +// --------------------------------------------------------------------------- + +async function fetchSnapshot( + client: PublicClient, + topology: TopologyGraph, +): Promise { + if (topology.root.kind !== 'plugin.dispatch') { + throw new Error('StatusView only supports DispatcherPlugin topologies'); + } + const dao = topology.root.dao; + const strategies = topology.root.strategies; + + // --- 1. Collect addresses + tokens from the topology --------------------- + + const tokens = new Map(); + const budgetReads: { + node: BudgetNode; + label: string; + strategy: StrategyNode; + }[] = []; + let gateNode: + | Extract< + StrategyNode, + { kind: 'strategy.dispatch.lido.gated-cowswap' } + >['gate'] + | null = null; + let cowSwapSettlement: Address | null = null; + let cowSwapTargetToken: TokenInfo | null = null; + let cowSwapSellToken: TokenInfo | null = null; + let streamBudgetNode: Extract< + BudgetNode, + { kind: 'budget.lido.stream-until' } + > | null = null; + let univ2Strategy: Extract< + StrategyNode, + { kind: 'strategy.dispatch.lido.univ2-liquidity' } + > | null = null; + const trackBudget = (n: BudgetNode) => { + tokens.set(n.token.address.toLowerCase(), n.token); + if (n.kind === 'budget.lido.stream-until' && !streamBudgetNode) { + streamBudgetNode = n; + } + }; + + for (const s of strategies) { + const label = strategyLabel(s); + + switch (s.kind) { + case 'strategy.dispatch.lido.wrap': + trackBudget(s.budget); + budgetReads.push({ node: s.budget, label, strategy: s }); + break; + case 'strategy.dispatch.lido.univ2-liquidity': + trackBudget(s.budget); + trackBudget(s.budgetB); + budgetReads.push({ + node: s.budget, + label: `${label} (LDO)`, + strategy: s, + }); + budgetReads.push({ + node: s.budgetB, + label: `${label} (stream)`, + strategy: s, + }); + univ2Strategy = s; + break; + case 'strategy.dispatch.lido.gated-cowswap': + trackBudget(s.budget); + budgetReads.push({ node: s.budget, label, strategy: s }); + gateNode = s.gate; + cowSwapSettlement = s.cowSwapSettlement; + cowSwapTargetToken = s.targetToken; + cowSwapSellToken = s.budget.token; + tokens.set(s.targetToken.address.toLowerCase(), s.targetToken); + break; + default: + if ('budget' in s && s.budget) { + trackBudget(s.budget); + } + } + } + + const block = await client.getBlock(); + + // --- 2. Issue all reads in parallel -------------------------------------- + + const balancesPromise = Promise.all( + [...tokens.values()].map(async (token) => ({ + token, + amount: await client.readContract({ + address: token.address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [dao], + }), + })), + ); + + const budgetsPromise = Promise.all( + budgetReads.map(async ({ node, label, strategy }) => { + const amount = await client + .readContract({ + address: node.address, + abi: budgetAbi, + functionName: 'budget', + }) + .catch(() => 0n); + return { + strategyAddress: strategy.address, + strategyKind: strategy.kind, + label, + budgetAddress: node.address, + budgetKind: node.kind, + token: node.token, + amount, + }; + }), + ); + + const gatePromise = gateNode + ? (async () => { + // Capture into a non-narrowable local so TS doesn't lose the type + // through the async boundary. + const g = gateNode!; + const [passes, priceResult] = await Promise.all([ + client.readContract({ + address: g.address, + abi: gateAbi, + functionName: 'passes', + }), + client + .readContract({ + address: g.oracle, + abi: oraclePriceAbi, + functionName: 'getPrice', + args: [g.tokenA, g.tokenB], + }) + .catch(() => null), + ]); + return { + address: g.address, + passes, + tokenA: g.tokenA, + tokenB: g.tokenB, + threshold: g.threshold, + price: priceResult ? priceResult[0] : null, + updatedAt: priceResult ? priceResult[1] : null, + maxStaleness: g.maxStaleness, + }; + })() + : Promise.resolve(undefined); + + const cowSwapPromise = + cowSwapSettlement && cowSwapSellToken + ? (async () => { + const settlement = cowSwapSettlement!; + const sellToken = cowSwapSellToken!; + const [ordersPlaced, allowance] = await Promise.all([ + client + .readContract({ + address: settlement, + abi: mockSettlementAbi, + functionName: 'ordersPlaced', + }) + .catch(() => null), + client.readContract({ + address: sellToken.address, + abi: erc20Abi, + functionName: 'allowance', + args: [dao, settlement], + }), + ]); + return { + settlement, + ordersPlaced, + allowance, + allowanceToken: sellToken, + }; + })() + : Promise.resolve(undefined); + + const streamPromise = streamBudgetNode + ? (async () => { + const sb = streamBudgetNode!; + const currentEpoch = await client.readContract({ + address: sb.epochProvider.address, + abi: epochProviderAbi, + functionName: 'getEpoch', + }); + const remaining = + sb.targetEpoch > currentEpoch + ? sb.targetEpoch - currentEpoch + : 0n; + return { + budgetAddress: sb.address, + token: sb.token, + targetEpoch: sb.targetEpoch, + floorEpochs: sb.floorEpochs, + currentEpoch, + remaining, + }; + })() + : Promise.resolve(undefined); + + const lpPromise = + univ2Strategy && cowSwapTargetToken + ? (async () => { + const u = univ2Strategy!; + const tokenA = u.budget.token.address; + const tokenB = u.budgetB.token.address; + const pair = await client.readContract({ + address: UNIV2_FACTORY, + abi: factoryAbi, + functionName: 'getPair', + args: [tokenA, tokenB], + }); + if (pair === zeroAddress) { + return { pair: null, recipientBalance: 0n }; + } + const recipientBalance = await client.readContract({ + address: pair, + abi: erc20Abi, + functionName: 'balanceOf', + args: [u.lpRecipient], + }); + return { pair, recipientBalance }; + })() + : Promise.resolve(undefined); + + // `Promise.all` with mixed-shape entries picks the wrong overload here + // (Iterable); awaiting each in sequence keeps the inferred + // shapes intact and the runtime cost is negligible (the in-flight + // promises were created above and are racing already). + const balances = await balancesPromise; + const budgets = await budgetsPromise; + const gate = await gatePromise; + const cowSwap = await cowSwapPromise; + const stream = await streamPromise; + const lp = await lpPromise; + + return { + block: block.number, + blockTimestamp: block.timestamp, + fetchedAt: Date.now(), + dao, + balances, + budgets, + ...(gate ? { gate } : {}), + ...(cowSwap ? { cowSwap } : {}), + ...(stream ? { stream } : {}), + ...(lp ? { lp } : {}), + // `nextDispatch` is filled in by the caller (the hook), which races + // simulate() against the snapshot fetch. + nextDispatch: null, + }; +} + +function strategyLabel(s: StrategyNode): string { + switch (s.kind) { + case 'strategy.dispatch.lido.wrap': + return 'Wrap'; + case 'strategy.dispatch.lido.univ2-liquidity': + return 'UniV2 LP'; + case 'strategy.dispatch.lido.gated-cowswap': + return 'Gated CowSwap'; + case 'strategy.dispatch.transfer': + return 'Transfer'; + case 'strategy.dispatch.burn': + return 'Burn'; + case 'strategy.dispatch.epoch-transfer': + return 'EpochTransfer'; + default: + return 'Strategy'; + } +} diff --git a/src/modules/flow/constants/flowDialogId.ts b/src/modules/flow/constants/flowDialogId.ts new file mode 100644 index 0000000000..8a8febf12c --- /dev/null +++ b/src/modules/flow/constants/flowDialogId.ts @@ -0,0 +1,3 @@ +export enum FlowDialogId { + CONFIRM_DISPATCH = 'FLOW_CONFIRM_DISPATCH', +} diff --git a/src/modules/flow/constants/flowDialogsDefinitions.ts b/src/modules/flow/constants/flowDialogsDefinitions.ts new file mode 100644 index 0000000000..bc3309af27 --- /dev/null +++ b/src/modules/flow/constants/flowDialogsDefinitions.ts @@ -0,0 +1,13 @@ +import type { IDialogComponentDefinitions } from '@/shared/components/dialogProvider'; +import { ConfirmDispatchDialog } from '../dialogs/confirmDispatchDialog'; +import { FlowDialogId } from './flowDialogId'; + +export const flowDialogsDefinitions: Record< + FlowDialogId, + IDialogComponentDefinitions +> = { + [FlowDialogId.CONFIRM_DISPATCH]: { + Component: ConfirmDispatchDialog, + size: 'md', + }, +}; diff --git a/src/modules/flow/demo/LmmDemoBanner.tsx b/src/modules/flow/demo/LmmDemoBanner.tsx new file mode 100644 index 0000000000..1a2a1884e8 --- /dev/null +++ b/src/modules/flow/demo/LmmDemoBanner.tsx @@ -0,0 +1,122 @@ +// LMM_DEMO_HACK: visually-loud banner that reminds the presenter (and the +// audience) that the page is running against a forked chain. Renders to +// `null` when demo mode is off. + +'use client'; + +import classNames from 'classnames'; +import { useEffect, useState } from 'react'; +import { + LMM_DEMO_MODE, + LMM_FLOW_INDEXER_ENDPOINT, + LMM_RPC_URL, + type LmmManifest, +} from './lmmDemoConfig'; +import { manifestFingerprintCheck } from './safety'; +import { useLmmManifest } from './useLmmManifest'; + +const FINGERPRINT_QUERY = + 'query LmmDaoFingerprint($addr: String!) { Dao(where: { address: { _eq: $addr } }, limit: 1) { address } }'; + +type FingerprintState = + | { status: 'unknown' } + | { status: 'ok' } + | { status: 'mismatch'; indexerDao: string }; + +const useFingerprint = ( + manifest: LmmManifest | undefined, +): FingerprintState => { + const [state, setState] = useState({ status: 'unknown' }); + useEffect(() => { + if (!LMM_DEMO_MODE || !manifest) { + return; + } + let cancelled = false; + const run = async () => { + try { + const res = await fetch(LMM_FLOW_INDEXER_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + body: JSON.stringify({ + query: FINGERPRINT_QUERY, + variables: { addr: manifest.lmm.dao.toLowerCase() }, + }), + }); + if (!res.ok) { + return; + } + const body = (await res.json()) as { + data?: { Dao?: Array<{ address: string }> }; + }; + const indexerDao = body.data?.Dao?.[0]?.address; + if (cancelled) { + return; + } + if (manifestFingerprintCheck(manifest, indexerDao)) { + setState({ status: 'ok' }); + } else if (indexerDao) { + setState({ status: 'mismatch', indexerDao }); + } + } catch { + // Network errors are non-fatal here — banner is informational. + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [manifest]); + return state; +}; + +export const LmmDemoBanner: React.FC<{ className?: string }> = (props) => { + const { className } = props; + const { manifest, error } = useLmmManifest(); + const fingerprint = useFingerprint(manifest); + if (!LMM_DEMO_MODE) { + return null; + } + return ( +
    + +
    + + Demo mode — running against {LMM_RPC_URL}. No real funds are + moving. + + {manifest && ( + + DAO {manifest.lmm.dao.slice(0, 6)}… + {manifest.lmm.dao.slice(-4)} · dispatcher{' '} + {manifest.lmm.dispatcherPlugin.slice(0, 6)}… + {manifest.lmm.dispatcherPlugin.slice(-4)} + + )} + {error && ( + + Manifest failed to load: {error.message} + + )} + {fingerprint.status === 'mismatch' && ( + + Fingerprint mismatch: indexer's first DAO is{' '} + {fingerprint.indexerDao.slice(0, 6)}… + {fingerprint.indexerDao.slice(-4)} but the manifest + points at {manifest?.lmm.dao.slice(0, 6)}… + {manifest?.lmm.dao.slice(-4)}. Re-run `just demo-up` or + re-sync the indexer. + + )} +
    +
    + ); +}; diff --git a/src/modules/flow/demo/lmmDaoOverride.ts b/src/modules/flow/demo/lmmDaoOverride.ts new file mode 100644 index 0000000000..c18071b54c --- /dev/null +++ b/src/modules/flow/demo/lmmDaoOverride.ts @@ -0,0 +1,364 @@ +// LMM_DEMO_HACK: synthesises IDao / IDaoPolicy[] / IDaoPermission[] payloads +// for the Lido Money Machine demo DAO by combining the manifest with live +// data from the Envio indexer. Production DAOs are not touched — every +// helper short-circuits to `undefined` so callers fall through to the +// standard `daoService` queries. +// +// Removal: +// 1. Drop the wrappers around `useDao*` queryFns. +// 2. Delete this file + lmmDemoConfig.ts. +// 3. Unset NEXT_PUBLIC_LMM_DEMO_MODE in Vercel. +// +// See docs/lido-mmd-production-readiness.md for the full checklist. + +import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; +import { + type IDao, + type IDaoPermission, + type IDaoPlugin, + type IDaoPolicy, + Network, + PluginInterfaceType, + PolicyInterfaceType, + PolicyStrategyType, +} from '@/shared/api/daoService/domain'; +import { + getLmmStrategies, + LMM_DEFAULT_METADATA, + LMM_DEMO_MODE, + LMM_FLOW_INDEXER_ENDPOINT, + LMM_MANIFEST_URL, + type LmmManifest, +} from './lmmDemoConfig'; + +// --------------------------------------------------------------------------- +// Manifest loader (mirrors lmmDemoConfig.fetchManifest but standalone — these +// override helpers run inside react-query's queryFn, outside React render). +// --------------------------------------------------------------------------- + +let cachedManifest: LmmManifest | undefined; +let inflightManifest: Promise | undefined; + +// LMM_DEMO_HACK: SSR + relative URL → read from public/ via fs (local dev, +// symlink works). SSR + absolute URL → fetch (production VM exposes manifest +// at https:///manifest.json via nginx). Browser → fetch always, +// regardless of relative/absolute. +// +// Without the fs branch Node.js fetch throws on relative URLs during SSR, +// the override silently fails and the page falls through to the Aragon +// backend → 404 → "DAO not found". +const fetchManifestRaw = async (): Promise => { + const isSSR = typeof window === 'undefined'; + const isRelative = !LMM_MANIFEST_URL.startsWith('http'); + + if (isSSR && isRelative) { + const { readFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const filePath = join( + process.cwd(), + 'public', + LMM_MANIFEST_URL.replace(/^\//, ''), + ); + return readFile(filePath, 'utf-8'); + } + + const r = await fetch(LMM_MANIFEST_URL, { cache: 'no-store' }); + if (!r.ok) { + throw new Error(`manifest fetch ${r.status} from ${LMM_MANIFEST_URL}`); + } + return r.text(); +}; + +const loadManifest = (): Promise => { + if (cachedManifest) { + return Promise.resolve(cachedManifest); + } + if (inflightManifest) { + return inflightManifest; + } + inflightManifest = fetchManifestRaw() + .then((raw) => JSON.parse(raw) as LmmManifest) + .then((m) => { + cachedManifest = m; + inflightManifest = undefined; + return m; + }) + .catch((e) => { + inflightManifest = undefined; + throw e; + }); + return inflightManifest; +}; + +// --------------------------------------------------------------------------- +// Envio fetcher +// --------------------------------------------------------------------------- + +const gql = async ( + query: string, + variables?: Record, +): Promise => { + const res = await fetch(LMM_FLOW_INDEXER_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + body: JSON.stringify({ query, variables }), + }); + if (!res.ok) { + throw new Error( + `envio ${res.status} from ${LMM_FLOW_INDEXER_ENDPOINT}`, + ); + } + const body = (await res.json()) as { + data?: T; + errors?: Array<{ message: string }>; + }; + if (body.errors && body.errors.length > 0) { + throw new Error( + `envio: ${body.errors.map((e) => e.message).join(', ')}`, + ); + } + if (!body.data) { + throw new Error('envio: empty response'); + } + return body.data; +}; + +// --------------------------------------------------------------------------- +// Identifier matching +// --------------------------------------------------------------------------- + +const matchesLmmDao = (manifest: LmmManifest, daoId: string): boolean => { + const id = daoId.toLowerCase(); + const addr = manifest.lmm.dao.toLowerCase(); + // Aragon daoId convention: `${network}-${address}` — match by substring + // because the address is unique enough on a fork. + return id.includes(addr); +}; + +const matchesLmmAddress = (manifest: LmmManifest, address: string): boolean => + address.toLowerCase() === manifest.lmm.dao.toLowerCase(); + +// --------------------------------------------------------------------------- +// Synthesised IDao +// --------------------------------------------------------------------------- + +interface EnvioDaoRow { + id: string; + address: string; + name?: string | null; + description?: string | null; + avatarUrl?: string | null; +} + +interface EnvioDaoQueryResult { + Dao: EnvioDaoRow[]; +} + +const LMM_DAO_QUERY = + 'query LmmDao($addr: String!) { Dao(where: { address: { _eq: $addr } }, limit: 1) { id address name description avatarUrl } }'; + +const fetchEnvioDao = async ( + daoAddress: string, +): Promise => { + const data = await gql(LMM_DAO_QUERY, { + addr: daoAddress.toLowerCase(), + }); + return data.Dao[0]; +}; + +const buildIDaoFromManifest = ( + manifest: LmmManifest, + envio: EnvioDaoRow | undefined, +): IDao => { + const address = manifest.lmm.dao; + // The fork emulates mainnet — surface it as ETHEREUM_MAINNET so the rest + // of the app's network routing (block explorer links, chain icons, etc.) + // points at the right network. Production demos would use the actual + // target network here. + const network = Network.ETHEREUM_MAINNET; + + return { + id: `${network}-${address.toLowerCase()}`, + address: address.toLowerCase(), + network, + name: + envio?.name ?? manifest.metadata?.name ?? LMM_DEFAULT_METADATA.name, + description: + envio?.description ?? + manifest.metadata?.description ?? + LMM_DEFAULT_METADATA.description, + ens: null, + subdomain: null, + avatar: + envio?.avatarUrl ?? + manifest.metadata?.avatarUrl ?? + LMM_DEFAULT_METADATA.avatarUrl ?? + null, + version: '1.4.0', // OSx version on the demo fork; updated by Foundry + isSupported: true, + plugins: [buildDispatcherAsIDaoPlugin(manifest)], + metrics: { + proposalsCreated: 0, + members: 0, + tvlUSD: '0', + }, + links: (manifest.metadata?.links ?? LMM_DEFAULT_METADATA.links).map( + (l) => ({ + name: l.label, + url: l.url, + }), + ), + // LMM demo never has linked accounts; emit an empty (but explicit) + // array so downstream `dao.linkedAccounts ?? []` patterns don't churn. + linkedAccounts: [], + blockTimestamp: 0, + transactionHash: '0x', + }; +}; + +const buildDispatcherAsIDaoPlugin = (manifest: LmmManifest): IDaoPlugin => ({ + name: 'Capital Dispatcher', + description: + 'Multi-dispatch Capital Router plugin (Lido Money Machine demo)', + address: manifest.lmm.dispatcherPlugin, + daoAddress: manifest.lmm.dao, + subdomain: 'capital-dispatcher', + interfaceType: PluginInterfaceType.CAPITAL_DISTRIBUTOR, + release: '1', + build: '1', + isProcess: false, + isBody: false, + isSubPlugin: false, + settings: {} as IDaoPlugin['settings'], + blockTimestamp: 0, + transactionHash: '0x', + slug: 'capital-dispatcher', +}); + +// --------------------------------------------------------------------------- +// Synthesised IDaoPolicy[] — built from manifest strategies + Envio state. +// --------------------------------------------------------------------------- + +const buildPoliciesFromManifest = (manifest: LmmManifest): IDaoPolicy[] => { + const dispatcherPolicy: IDaoPolicy = { + name: 'Lido Money Machine', + description: + 'Routes incoming stETH through a wrap → LP-provide → CowSwap buyback pipeline, ' + + 'with each leg metered by its own budget and price-floor gate.', + policyKey: 'lmm-dispatcher', + address: manifest.lmm.dispatcherPlugin, + daoAddress: manifest.lmm.dao, + interfaceType: PolicyInterfaceType.ROUTER, + strategy: { + type: PolicyStrategyType.MULTI_DISPATCH, + subRouters: getLmmStrategies(manifest).map((s) => s.address), + }, + release: '1', + build: '1', + blockTimestamp: 0, + transactionHash: '0x', + }; + + return [dispatcherPolicy]; +}; + +// --------------------------------------------------------------------------- +// Public override API — used by useDao* hooks +// --------------------------------------------------------------------------- + +export const tryLmmDaoOverride = async ( + daoId: string, +): Promise => { + if (!LMM_DEMO_MODE) { + return undefined; + } + const manifest = await loadManifest().catch(() => undefined); + if (!manifest) { + return undefined; + } + if (!matchesLmmDao(manifest, daoId)) { + return undefined; + } + + const envio = await fetchEnvioDao(manifest.lmm.dao).catch(() => undefined); + return buildIDaoFromManifest(manifest, envio); +}; + +export const tryLmmDaoByEnsOverride = async ( + _network: Network, + ens: string, +): Promise => { + if (!LMM_DEMO_MODE) { + return undefined; + } + const manifest = await loadManifest().catch(() => undefined); + if (!manifest) { + return undefined; + } + // The LMM demo doesn't register a real ENS; we accept the manifest's + // metadata.name (or LMM_DEFAULT_METADATA.name when the manifest omits it) + // as an ENS-ish slug (lowercased) so URLs like + // ///* resolve to the demo DAO. + const slug = (manifest.metadata?.name ?? LMM_DEFAULT_METADATA.name) + .toLowerCase() + .replace(/\s+/g, '-'); + if (ens.toLowerCase() !== slug) { + return undefined; + } + + const envio = await fetchEnvioDao(manifest.lmm.dao).catch(() => undefined); + return buildIDaoFromManifest(manifest, envio); +}; + +export const tryLmmDaoPoliciesOverride = async ( + daoAddress: string, +): Promise => { + if (!LMM_DEMO_MODE) { + return undefined; + } + const manifest = await loadManifest().catch(() => undefined); + if (!manifest) { + return undefined; + } + if (!matchesLmmAddress(manifest, daoAddress)) { + return undefined; + } + + return buildPoliciesFromManifest(manifest); +}; + +export const tryLmmDaoPermissionsOverride = async ( + daoAddress: string, +): Promise | undefined> => { + if (!LMM_DEMO_MODE) { + return undefined; + } + const manifest = await loadManifest().catch(() => undefined); + if (!manifest) { + return undefined; + } + if (!matchesLmmAddress(manifest, daoAddress)) { + return undefined; + } + + // The demo doesn't expose permission rows to the front-end (no auth UI + // is reachable for demo DAOs). Return an empty page so the hook + // resolves successfully but no permission UI renders. + return { + data: [], + metadata: { page: 1, pageSize: 0, totalPages: 1, totalRecords: 0 }, + } satisfies IPaginatedResponse; +}; + +// Used by useLmmManifest.useIsLmmDemoDao via a cached synchronous check. +export const isLmmDemoDaoSync = (daoAddress: string | undefined): boolean => { + if (!LMM_DEMO_MODE || !daoAddress || !cachedManifest) { + return false; + } + return cachedManifest.lmm.dao.toLowerCase() === daoAddress.toLowerCase(); +}; + +// `void` exports so the bundler keeps the helpers reachable even when none +// of the wrapper hooks reference them in a given build. +export { LMM_DEMO_MODE }; diff --git a/src/modules/flow/demo/lmmDemoConfig.ts b/src/modules/flow/demo/lmmDemoConfig.ts new file mode 100644 index 0000000000..61c32e1afa --- /dev/null +++ b/src/modules/flow/demo/lmmDemoConfig.ts @@ -0,0 +1,217 @@ +// LMM_DEMO_HACK: demo-mode flags + manifest *type/URL* config for the Lido +// Money Machine demo. This module must stay free of React imports — it's +// reached from the server-component graph via `lmmDaoOverride.ts → useDao.ts`, +// and Next 16 / Turbopack rejects RSC modules that pull in useEffect/useState. +// The React hooks that consume this config live in `./useLmmManifest`. +// +// See `docs/lido-mmd-production-readiness.md` for the removal checklist. + +import type { Address } from 'viem'; + +/** `1` when the build should expose the LMM demo affordances. */ +export const LMM_DEMO_MODE: boolean = + process.env.NEXT_PUBLIC_LMM_DEMO_MODE === '1' || + process.env.NEXT_PUBLIC_LMM_DEMO_MODE === 'true'; + +// LMM_DEMO_HACK: refuse to start a production build with demo mode on. +// `NEXT_PUBLIC_ENV=production` only ships from the Production scope of the +// Vercel project; if someone copies the preview env vars by mistake we'd +// rather hard-fail the build than render a demo banner over a real DAO. +if (LMM_DEMO_MODE && process.env.NEXT_PUBLIC_ENV === 'production') { + throw new Error( + '[lmm-demo] refusing to evaluate with NEXT_PUBLIC_LMM_DEMO_MODE=1 ' + + 'on a NEXT_PUBLIC_ENV=production build. Unset the demo flag in ' + + 'the production Vercel scope (it should only live on Preview).', + ); +} + +/** + * URL the front-end fetches at boot to discover the demo DAO's addresses. + * Produced by `just demo-up` in dao-launchpad@f/lido-demo and served by the + * VM's host nginx (fronted by Cloudflare) at `https:///manifest.json` — + * see `infra/lmm-demo/vm/nginx.lmm-demo.conf`. For local-first dev we + * default to the dev server's `/lmm-manifest.json` (a symlink to + * `dao-launchpad/lido/preview/script/demo/manifest.json`). + */ +export const LMM_MANIFEST_URL: string = + process.env.NEXT_PUBLIC_LMM_MANIFEST_URL ?? '/lmm-manifest.json'; + +/** + * Envio Hasura endpoint used in demo mode. In local-first dev: + * http://localhost:8080/v1/graphql + * On the VM: + * https:///graphql + */ +export const LMM_FLOW_INDEXER_ENDPOINT: string = + process.env.NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT ?? + 'http://localhost:8080/v1/graphql'; + +/** + * Anvil RPC used by the demo's cheat-action menu (anvil_setBalance, + * evm_increaseTime). This must point at the same node Envio is indexing. + */ +export const LMM_RPC_URL: string = + process.env.NEXT_PUBLIC_LMM_RPC_URL ?? 'http://localhost:8545'; + +/** + * Whitelist of hosts we'll let viem talk to in demo mode. Used by + * `assertForkRpc()` in safety.ts to block accidental writes against + * real chains. + */ +export const LMM_RPC_ALLOWLIST = [ + 'localhost', + '127.0.0.1', + // Aragon demo VM (Cloudflare → host nginx → docker anvil). See + // infra/lmm-demo/vm/vm-README.md. Add new hostnames here as new + // demo VMs spin up; assertForkRpc() blocks anything not in this list. + 'tests.aragon.in', +]; + +// --------------------------------------------------------------------------- +// Manifest shape +// --------------------------------------------------------------------------- + +/** + * Real shape of `manifest.json` as emitted by `just demo-up` in + * dao-launchpad@f/lido-demo. Keep this aligned with `script/demo/demo.just` + * — every key that the override layer or the cheats menu reads must show up + * here (or under `[k: string]: unknown` for forward-compat duck-typing in + * `deriveAddressesFromManifest`). + */ +export interface LmmManifest { + /** Chain id the demo was deployed on (1 = anvil mainnet fork). */ + chainId: number; + /** Anvil fork's HEAD block at deploy time. Informational. */ + blockNumber?: number; + /** Anvil fork's HEAD timestamp at deploy time. Informational. */ + timestamp?: number; + /** Anvil RPC URL the demo was deployed against. Informational only — + * the app reads its RPC from NEXT_PUBLIC_LMM_RPC_URL, not from here. */ + rpcUrl?: string; + + /** The Lido Money Machine DAO + its plugin surface. */ + lmm: { + dao: Address; + /** Top-level dispatcher plugin (multi-dispatch policy). */ + dispatcherPlugin: Address; + /** Auxiliary primitives. All optional — only the strategies need to + * be present for the override layer to render a policy. */ + epochProvider?: Address; + factory?: Address; + gate?: Address; + /** Embedded strategies. Object with named keys (NOT an array); see + * `getLmmStrategies()` below for the canonical ordered list. */ + strategies: { + wrap: Address; + uniV2: Address; + cowSwap: Address; + }; + budgets?: { + ldo?: Address; + stETH?: Address; + wstEthStream?: Address; + }; + }; + + /** Lido + mainnet token addresses (real mainnet — readable on the fork). */ + lido?: { + agent?: Address; + ldo?: Address; + stETH?: Address; + uniV2Router?: Address; + usdc?: Address; + weth?: Address; + wstETH?: Address; + }; + + /** Mock CowSwap settlement contract (deployed alongside the demo DAO). */ + cowSwap?: { mock?: boolean; settlement?: Address }; + /** Mock price oracle (used by GatedCowSwap's price-floor gate). */ + oracle?: { address?: Address; mock?: boolean; seededPairs?: string }; + /** Demo-only addresses + parameters used by the cheats menu. */ + demo?: { + deployer?: Address; + epochLengthSeconds?: number; + floorEpochs?: number; + initialOffsetEpochs?: number; + initialStEthAmount?: string; + stEthWhale?: Address; + }; + /** Capital Router factory + plugin-repo deployment addresses. */ + cr?: { + budgetFactory?: Address; + dispatcherPluginRepo?: Address; + dispatcherPluginSetup?: Address; + }; + + /** + * Optional DAO metadata. `just demo-up` does NOT emit this key today — + * see `LMM_DEFAULT_METADATA` below for the fallback that the override + * layer uses when it's absent. Future demos can write `metadata` to + * override the defaults without touching app code. + */ + metadata?: { + name?: string; + description?: string; + avatarUrl?: string; + links?: Array<{ label: string; url: string }>; + }; + /** + * Fingerprint of the deployment. Compared against the indexer's + * Dao.address on load — mismatches signal a stale manifest or wrong + * indexer endpoint. See safety.ts → manifestFingerprintCheck(). + */ + fingerprint?: string; +} + +/** + * Canonical strategy order for the LMM demo dispatcher: `wrap → uniV2 → cowSwap`. + * Mirrors the dispatch pipeline in the foundry deploy script — every consumer + * that needs an ordered list of strategies should call this helper rather + * than enumerating the `lmm.strategies` object directly (object property + * iteration order is not guaranteed across runtimes). + */ +export const getLmmStrategies = ( + manifest: LmmManifest, +): Array<{ + address: Address; + kind: 'WRAP' | 'UNIV2_LIQUIDITY' | 'GATED_COWSWAP'; + label: string; +}> => [ + { + address: manifest.lmm.strategies.wrap, + kind: 'WRAP', + label: 'Wrap stETH → wstETH', + }, + { + address: manifest.lmm.strategies.uniV2, + kind: 'UNIV2_LIQUIDITY', + label: 'Provide wstETH/LDO liquidity', + }, + { + address: manifest.lmm.strategies.cowSwap, + kind: 'GATED_COWSWAP', + label: 'Buyback LDO via CowSwap', + }, +]; + +// --------------------------------------------------------------------------- +// Defaults for missing manifest.metadata. +// +// `just demo-up` (dao-launchpad@f/lido-demo) does not emit `metadata` today. +// We fill in human-friendly defaults so the override layer can still +// synthesise an IDao without crashing. Production demos can override by +// writing the `metadata` block into the manifest themselves. +// --------------------------------------------------------------------------- + +export const LMM_DEFAULT_METADATA = { + name: 'Lido Money Machine', + description: + 'Capital Router demo on a forked Ethereum mainnet (anvil). Routes incoming stETH through a wrap → LP-provide → CowSwap buyback pipeline, with each leg metered by its own budget and price-floor gate.', + avatarUrl: undefined as string | undefined, + links: [] as Array<{ label: string; url: string }>, +} as const; + +// --------------------------------------------------------------------------- +// React hooks + mutable client cache: see `./useLmmManifest` +// --------------------------------------------------------------------------- diff --git a/src/modules/flow/demo/safety.test.ts b/src/modules/flow/demo/safety.test.ts new file mode 100644 index 0000000000..cc2236d12d --- /dev/null +++ b/src/modules/flow/demo/safety.test.ts @@ -0,0 +1,63 @@ +// LMM_DEMO_HACK: regression tests for the demo safety guardrails. These +// exist so a careless change to `LMM_RPC_ALLOWLIST` or `assertForkRpc()` +// can't silently allow demo writes against a production chain. + +import type { LmmManifest } from './lmmDemoConfig'; +import { assertForkRpc, manifestFingerprintCheck } from './safety'; + +const FAKE_MANIFEST: LmmManifest = { + chainId: 1, + lmm: { + dao: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + dispatcherPlugin: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + strategies: { + wrap: '0xC1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1', + uniV2: '0xC2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2', + cowSwap: '0xC3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3', + }, + }, +}; + +describe('flow/demo/safety', () => { + describe('assertForkRpc', () => { + it('accepts localhost RPCs', () => { + expect(() => assertForkRpc('http://localhost:8545')).not.toThrow(); + }); + it('accepts 127.0.0.1 RPCs', () => { + expect(() => assertForkRpc('http://127.0.0.1:8545')).not.toThrow(); + }); + it('refuses an arbitrary mainnet RPC', () => { + expect(() => + assertForkRpc('https://mainnet.infura.io/v3/abc'), + ).toThrow(/refusing to use RPC/); + }); + it('refuses a similar-looking domain that is not in the allowlist', () => { + expect(() => assertForkRpc('https://aragon.org')).toThrow( + /refusing to use RPC/, + ); + }); + }); + describe('manifestFingerprintCheck', () => { + it('passes when indexer DAO matches manifest', () => { + expect( + manifestFingerprintCheck( + FAKE_MANIFEST, + FAKE_MANIFEST.lmm.dao.toLowerCase(), + ), + ).toBe(true); + }); + it('fails when indexer DAO differs', () => { + expect( + manifestFingerprintCheck( + FAKE_MANIFEST, + '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + ), + ).toBe(false); + }); + it('returns true when no indexer DAO is supplied', () => { + expect(manifestFingerprintCheck(FAKE_MANIFEST, undefined)).toBe( + true, + ); + }); + }); +}); diff --git a/src/modules/flow/demo/safety.ts b/src/modules/flow/demo/safety.ts new file mode 100644 index 0000000000..20baaa656f --- /dev/null +++ b/src/modules/flow/demo/safety.ts @@ -0,0 +1,69 @@ +// LMM_DEMO_HACK: safety helpers for the Lido Money Machine demo. +// +// The demo bypasses every real-money guard the app normally has: +// - it overrides DAO/policies queries with manifest+envio data +// - it bypasses wagmi for dispatch (using --auto-impersonate on Anvil) +// - it exposes a cheats menu that warps time, mints tokens, etc. +// +// These helpers are the line of defense ensuring the demo *only* ever talks +// to a fork RPC and a known indexer. If any of them fail at boot we refuse +// to render demo UI. + +import type { LmmManifest } from './lmmDemoConfig'; +import { LMM_RPC_ALLOWLIST, LMM_RPC_URL } from './lmmDemoConfig'; + +/** + * Returns the hostname for a given URL (best-effort — falls back to the raw + * URL when the parser blows up so the allowlist check stays strict). + */ +const hostOf = (raw: string): string => { + try { + return new URL(raw).hostname; + } catch { + return raw; + } +}; + +/** + * Hard-fails when the demo RPC is not in the allowlist of approved hosts. + * Mounted at the entry-point of every demo-only code path so production + * tabs that accidentally have `NEXT_PUBLIC_LMM_DEMO_MODE=1` set against a + * mainnet RPC URL refuse to fire any tx. + */ +export const assertForkRpc = (rpcUrl: string = LMM_RPC_URL): void => { + const host = hostOf(rpcUrl).toLowerCase(); + const ok = LMM_RPC_ALLOWLIST.some((allowed) => + host === allowed.toLowerCase() + ? true + : // Subdomain match — only when the allowlist entry starts with a + // dot (".aragon.in" would match "tests.aragon.in"). We + // intentionally do NOT do open-ended suffix matching to keep + // the rule list explicit. + allowed.startsWith('.') && host.endsWith(allowed.toLowerCase()), + ); + if (!ok) { + const allowlist = LMM_RPC_ALLOWLIST.join(', '); + throw new Error( + `[lmm-demo] refusing to use RPC ${rpcUrl}: host "${host}" is not in the demo allowlist (${allowlist}). If this is a new demo VM, add its hostname to LMM_RPC_ALLOWLIST.`, + ); + } +}; + +/** + * Best-effort fingerprint check between a freshly-loaded manifest and the + * indexer's notion of the LMM DAO. Returns `true` when they're consistent + * (or when the manifest doesn't carry a fingerprint), `false` otherwise. + * Callers should refuse to fire writes when this returns `false`. + */ +export const manifestFingerprintCheck = ( + manifest: LmmManifest, + indexerDaoAddress: string | undefined, +): boolean => { + if (!indexerDaoAddress) { + return true; + } + if (manifest.lmm.dao.toLowerCase() !== indexerDaoAddress.toLowerCase()) { + return false; + } + return true; +}; diff --git a/src/modules/flow/demo/strategyParams.ts b/src/modules/flow/demo/strategyParams.ts new file mode 100644 index 0000000000..3ad7ee89a8 --- /dev/null +++ b/src/modules/flow/demo/strategyParams.ts @@ -0,0 +1,69 @@ +'use client'; + +// LMM_DEMO_HACK: swap/strategy config parameters (slippage, target token, +// oracle staleness) are NOT part of the indexer's generic taxonomy — they only +// exist on the inspected `TopologyGraph`. This thin reader projects them into +// generic `{label, value}` display pairs keyed by strategy address, so the +// canvas/inspector can render them without any per-strategy knowledge. Outside +// demo mode there is no topology and this simply yields an empty map. +// +// Production removal: when the indexer surfaces strategy config (e.g. on +// `DispatcherSettingsUpdated`), source these from `IFlowEmbeddedStrategy` +// instead and delete this file. + +import type { StrategyNode, TopologyGraph } from '@/shared/lidoPreview'; +import type { IFlowStrategyParam } from '../canvas/flowGraphTypes'; + +/** Basis points → percent string, e.g. 50 → "0.50%". */ +const pct = (bps: number | bigint): string => + `${(Number(bps) / 100).toFixed(2)}%`; + +/** Seconds → compact hours/minutes, e.g. 3600 → "1h", 1800 → "30m". */ +const maxAge = (seconds: bigint): string => { + const s = Number(seconds); + if (s % 3600 === 0) { + return `${s / 3600}h`; + } + if (s >= 60) { + return `${Math.round(s / 60)}m`; + } + return `${s}s`; +}; + +const paramsForStrategy = (node: StrategyNode): IFlowStrategyParam[] => { + switch (node.kind) { + case 'strategy.dispatch.lido.univ2-liquidity': + return [ + { label: 'Max slippage', value: pct(node.maxSlippageBps) }, + { label: 'Oracle max age', value: maxAge(node.maxStaleness) }, + ]; + case 'strategy.dispatch.lido.gated-cowswap': + return [ + { label: 'Buys', value: node.targetToken.symbol ?? '—' }, + { label: 'Max slippage', value: pct(node.maxSlippageBps) }, + { label: 'Oracle max age', value: maxAge(node.maxStaleness) }, + ]; + default: + return []; + } +}; + +/** + * Build a `strategyAddress(lowercased) → display params` map from a topology. + * Strategies with no surfaced params are omitted. + */ +export const strategyParamsByAddress = ( + topology: TopologyGraph | null, +): Map => { + const map = new Map(); + if (topology?.root.kind !== 'plugin.dispatch') { + return map; + } + for (const node of topology.root.strategies) { + const params = paramsForStrategy(node); + if (params.length > 0) { + map.set(node.address.toLowerCase(), params); + } + } + return map; +}; diff --git a/src/modules/flow/demo/useLmmActionContext.ts b/src/modules/flow/demo/useLmmActionContext.ts new file mode 100644 index 0000000000..5664fca5bf --- /dev/null +++ b/src/modules/flow/demo/useLmmActionContext.ts @@ -0,0 +1,50 @@ +'use client'; + +// LMM_DEMO_HACK: builds the viem `ActionContext` the demo write-helpers +// (dispatch / settle / warp / top-up) need from the loaded manifest. Shared by +// the demo cheats dropdown and the Focus "Simulate & dispatch" modal so both +// drive the same Anvil fork the same way. Returns `undefined` outside demo mode +// or before the manifest loads. + +import { useMemo } from 'react'; +import { createPublicClient, http, type PublicClient } from 'viem'; +import { mainnet } from 'viem/chains'; +import { + type ActionContext, + deriveAddressesFromManifest, +} from '../components/lidoMoneyMachine/actions'; +import { LMM_DEMO_MODE, LMM_RPC_URL } from './lmmDemoConfig'; +import { useLmmManifest } from './useLmmManifest'; + +let cachedClient: PublicClient | undefined; +const getPublicClient = (): PublicClient => { + cachedClient ??= createPublicClient({ + chain: mainnet, + transport: http(LMM_RPC_URL), + }); + return cachedClient; +}; + +export const useLmmActionContext = (): ActionContext | undefined => { + const { manifest } = useLmmManifest(); + return useMemo(() => { + if (!(LMM_DEMO_MODE && manifest)) { + return undefined; + } + const addresses = deriveAddressesFromManifest( + manifest as unknown as Parameters< + typeof deriveAddressesFromManifest + >[0], + ); + if (!addresses) { + return undefined; + } + return { + rpc: LMM_RPC_URL, + publicClient: getPublicClient(), + dao: manifest.lmm.dao, + dispatcher: manifest.lmm.dispatcherPlugin, + addresses, + }; + }, [manifest]); +}; diff --git a/src/modules/flow/demo/useLmmChainNow.ts b/src/modules/flow/demo/useLmmChainNow.ts new file mode 100644 index 0000000000..02650f5a69 --- /dev/null +++ b/src/modules/flow/demo/useLmmChainNow.ts @@ -0,0 +1,133 @@ +'use client'; + +// LMM_DEMO_HACK: the dispatcher's per-leg cooldown is enforced ON-CHAIN against +// `block.timestamp`, but the rest of the UI computes "is the cooldown done?" +// against the host wall-clock (`Date.now()`). When the operator uses the +// cheats menu to warp anvil forward by 7+ days, anvil's clock jumps but the +// browser clock obviously doesn't — so the Dispatch button stays disabled +// even though the chain would happily accept a dispatch. Polling anvil's +// HEAD block timestamp here lets the cooldown check use chain time instead +// of wall time, which is exactly what the on-chain side does. +// +// Production removal: drop this file (and its imports in flowDataProvider). +// Outside demo mode `chainNowMs` is always undefined and consumers fall back +// to Date.now(), so no production code path depends on it. + +import { useEffect, useRef, useState } from 'react'; +import { createPublicClient, http } from 'viem'; +import { mainnet } from 'viem/chains'; +import { LMM_DEMO_MODE, LMM_RPC_URL } from './lmmDemoConfig'; + +/** + * Wall time (ms since epoch) is fixed; we re-poll on this cadence so warps + * applied via the cheats menu propagate to the UI within a few seconds. + */ +const POLL_INTERVAL_MS = 5000; + +/** + * Drift threshold (ms) for the very first poll. If anvil HEAD is within this + * window of the host wall-clock we treat the fork as "not warped" and return + * `undefined` so callers stay on `Date.now()` — that avoids a flicker on the + * dispatch button right after first render of the LMM detail page. + */ +const NO_WARP_DRIFT_MS = 60_000; + +/** + * Process-wide cache so every consumer that mounts shares the same poller — + * we don't want each `useFlowDataContext()` call site to spin up its own + * 5s interval against the same RPC. + */ +let cachedChainNowMs: number | undefined; +let pollerStarted = false; +const listeners = new Set<(v: number | undefined) => void>(); + +const broadcast = (v: number | undefined): void => { + cachedChainNowMs = v; + for (const fn of listeners) { + fn(v); + } +}; + +const startPoller = (): void => { + if (pollerStarted) { + return; + } + pollerStarted = true; + + const client = createPublicClient({ + chain: mainnet, + transport: http(LMM_RPC_URL), + }); + + const tick = async (): Promise => { + try { + const block = await client.getBlock({ blockTag: 'latest' }); + const chainMs = Number(block.timestamp) * 1000; + const wallMs = Date.now(); + // Only override Date.now() once the fork has drifted meaningfully — + // fresh deployments where anvil ≈ wall time should keep using the + // host clock so the cooldown ring animation stays smooth. + if (Math.abs(chainMs - wallMs) >= NO_WARP_DRIFT_MS) { + broadcast(chainMs); + } else if (cachedChainNowMs != null) { + // Already overridden once — keep tracking even if we drift + // back near the wall clock (operator may reset the fork). + broadcast(chainMs); + } + } catch (e) { + // Silently ignore — the LMM RPC may be momentarily unreachable + // (operator restarting anvil). Consumers fall back to Date.now() + // until the next successful poll. + // biome-ignore lint/suspicious/noConsole: surface RPC hiccups in demo mode for debugging + console.warn('[useLmmChainNow] poll failed:', e); + } + }; + + void tick(); + setInterval(() => { + void tick(); + }, POLL_INTERVAL_MS); +}; + +/** + * Returns the latest known anvil block timestamp (ms), or `undefined` when: + * - LMM demo mode is off, OR + * - the first poll hasn't completed yet, OR + * - the fork's clock is within {@link NO_WARP_DRIFT_MS} of the wall clock. + * + * Consumers should fall back to `Date.now()` on `undefined`. + */ +export const useLmmChainNow = (): { chainNowMs: number | undefined } => { + const [chainNowMs, setChainNowMs] = useState( + cachedChainNowMs, + ); + const listenerRef = useRef<(v: number | undefined) => void>(setChainNowMs); + listenerRef.current = setChainNowMs; + + useEffect(() => { + if (!LMM_DEMO_MODE) { + return; + } + startPoller(); + const fn = (v: number | undefined): void => listenerRef.current(v); + listeners.add(fn); + if (cachedChainNowMs != null) { + setChainNowMs(cachedChainNowMs); + } + return () => { + listeners.delete(fn); + }; + }, []); + + return { chainNowMs }; +}; + +/** + * Returns the "effective now" ms for cooldown / status comparisons: + * - `chainNowMs` when the LMM fork is warped, OR + * - `Date.now()` everywhere else. + * + * Read-once helper for non-React call sites (e.g. selectors). React + * consumers should prefer the hook above so they re-render on warp. + */ +export const getEffectiveNowMs = (): number => cachedChainNowMs ?? Date.now(); diff --git a/src/modules/flow/demo/useLmmLiveSnapshot.ts b/src/modules/flow/demo/useLmmLiveSnapshot.ts new file mode 100644 index 0000000000..4e2dd80812 --- /dev/null +++ b/src/modules/flow/demo/useLmmLiveSnapshot.ts @@ -0,0 +1,192 @@ +'use client'; + +// LMM_DEMO_HACK: lift the vendored `useStatus` topology+RPC reader out of the +// per-page `LmmPolicyTopology` and expose it as a global snapshot via the +// FlowDataProvider. Two motivations: +// +// 1. KPI / Activity / Recipients on the overview page need the same +// "what does the next dispatch look like" prediction the detail page +// already shows — re-using the existing hook is the smallest possible +// change. +// 2. Single poller per session: useStatus already de-dupes inside itself, +// but we'd previously spin up an `inspect()` + a poll loop every time +// `LmmPolicyTopology` mounted. One snapshot driven from the provider +// keeps the RPC churn predictable. +// +// Production removal: the snapshot fields below are 1-to-1 with what an +// `OrchestratorSnapshot` entity could surface from the indexer (per-strategy +// budget readings, gate.passes, cowSwap.ordersPlaced, simulate.steps). +// When the indexer emits that, this hook degrades to a thin selector over +// `IEnvioPolicy.strategies[].budget.snapshot` and the RPC + viem dependency +// can disappear. See docs/lido-mmd-status.md `live-snapshot-rpc`. + +import { useEffect, useRef, useState } from 'react'; +import { + type Address, + createPublicClient, + http, + type PublicClient, +} from 'viem'; +import { mainnet } from 'viem/chains'; +import { inspect, type TopologyGraph } from '@/shared/lidoPreview'; +import { + type StatusSnapshot, + useStatus, +} from '../components/lidoMoneyMachine/useStatus'; +import { LMM_DEMO_MODE, LMM_RPC_URL } from './lmmDemoConfig'; +import { useLmmManifest } from './useLmmManifest'; + +export type LmmLiveSnapshot = StatusSnapshot; + +export interface IUseLmmLiveSnapshotResult { + /** Dispatcher plugin address (manifest), used by selectors to attach + * the snapshot to the matching `IFlowPolicy`. */ + dispatcherAddress?: string; + /** On-chain topology graph; null until `inspect()` resolves. Re-exposed + * so downstream consumers (e.g. money-flow graph builder) don't have + * to re-run inspect themselves. */ + topology: TopologyGraph | null; + /** Latest snapshot from `useStatus` — paused/budget/gate/simulate. */ + snapshot: LmmLiveSnapshot | null; + /** `true` while we don't yet have a snapshot to render. */ + loading: boolean; + /** Last error encountered while inspecting / polling. */ + error?: string; +} + +// Cadence for re-running inspect(). inspect() bakes MUTABLE per-strategy +// state (notably each strategy's `lastEpoch` / `lastDispatchedEpoch` and the +// stream budget's `targetEpoch`) into the topology alongside the immutable +// structure. The simulator (`simulate()` inside useStatus) compares the +// *frozen* `lastEpoch` against the *fresh* current epoch it reads each poll — +// so once a dispatch burns the epoch slot, the legs keep reporting "would +// fire" (canvas draws arrows + sums) forever, because nothing re-reads +// `lastEpoch`. A page reload re-ran inspect() and "fixed" it, which is the +// bug the demo hit. Re-inspecting on the snapshot cadence keeps those fields +// live so the canvas flips to "already dispatched" within one tick. +const INSPECT_POLL_MS = 5000; + +// One PublicClient per session — Anvil's fork RPC is the same across renders. +let cachedClient: PublicClient | undefined; +const getClient = (): PublicClient => { + if (cachedClient) { + return cachedClient; + } + cachedClient = createPublicClient({ + chain: mainnet, + transport: http(LMM_RPC_URL), + }); + return cachedClient; +}; + +/** + * Stable comparison key for a topology — JSON with bigints stringified. + * Used to skip React state churn when a re-inspect returns structurally and + * value-identical data, while still detecting mutable-field changes (epochs, + * budgets, gate config) that must reach the simulator. + */ +const topologyKey = (topology: TopologyGraph): string => + JSON.stringify(topology, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ); + +const EMPTY: IUseLmmLiveSnapshotResult = { + dispatcherAddress: undefined, + topology: null, + snapshot: null, + loading: false, +}; + +/** + * Resolve the LMM topology + live snapshot, or return `EMPTY` outside demo + * mode / before the manifest loads. + */ +export const useLmmLiveSnapshot = (): IUseLmmLiveSnapshotResult => { + const { manifest } = useLmmManifest(); + const [topology, setTopology] = useState(null); + const [inspectError, setInspectError] = useState( + undefined, + ); + const dispatcher = manifest?.lmm.dispatcherPlugin as Address | undefined; + + // Memoise dispatcher in a ref so the inspect effect depends only on + // the string identity rather than the manifest object — Turbopack / + // fast-refresh can churn the latter. + const dispatcherRef = useRef(dispatcher); + dispatcherRef.current = dispatcher; + + useEffect(() => { + if (!LMM_DEMO_MODE || dispatcher == null) { + setTopology(null); + return; + } + let cancelled = false; + const run = async (): Promise => { + try { + const result = await inspect(getClient(), dispatcher); + if (cancelled) { + return; + } + setInspectError(undefined); + // Only push a new topology object when the (re-inspected) + // mutable state actually changed — otherwise the steady-state + // poll would churn the topology reference every tick and reset + // every topology-keyed effect downstream. When `lastEpoch` + // (etc.) advances after a dispatch, the key differs and the + // fresh topology flows into the simulator. + setTopology((prev) => + prev && topologyKey(prev) === topologyKey(result) + ? prev + : result, + ); + } catch (e) { + if (!cancelled) { + setInspectError(e instanceof Error ? e.message : String(e)); + setTopology(null); + } + } + }; + void run(); + // Re-inspect on the poll cadence so the topology's mutable epoch state + // stays live (see INSPECT_POLL_MS). Pauses while the tab is hidden and + // snaps to a fresh read on re-show, mirroring useStatus's poller. + const handle = window.setInterval(() => { + if (!document.hidden) { + void run(); + } + }, INSPECT_POLL_MS); + const onVisibility = () => { + if (!document.hidden) { + void run(); + } + }; + document.addEventListener('visibilitychange', onVisibility); + return () => { + cancelled = true; + window.clearInterval(handle); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [dispatcher]); + + // useStatus self-disables when topology is null, so the polling loop + // never runs outside demo mode. + const { state } = useStatus(getClient, topology); + + if (!LMM_DEMO_MODE) { + return EMPTY; + } + + const snapshot = state.kind === 'ready' ? state.snapshot : null; + const loading = + topology == null || (state.kind === 'loading' && snapshot == null); + const error = + inspectError ?? (state.kind === 'error' ? state.message : undefined); + + return { + dispatcherAddress: dispatcher, + topology, + snapshot, + loading, + error, + }; +}; diff --git a/src/modules/flow/demo/useLmmManifest.ts b/src/modules/flow/demo/useLmmManifest.ts new file mode 100644 index 0000000000..983a33c811 --- /dev/null +++ b/src/modules/flow/demo/useLmmManifest.ts @@ -0,0 +1,165 @@ +'use client'; + +// LMM_DEMO_HACK: React-only entry-point for the manifest loader. The pure +// config exports (URLs, flags, types) live in `./lmmDemoConfig` so that +// server components can import them without dragging useEffect/useState +// into the RSC graph — Turbopack rejects that in Next 16. This module is +// the one place where the demo's mutable client cache lives. +// +// Removal: +// 1. Delete this file together with ./lmmDemoConfig and ./lmmDaoOverride. +// 2. See docs/lido-mmd-production-readiness.md. + +import { useEffect, useState } from 'react'; +import { + LMM_DEMO_MODE, + LMM_MANIFEST_URL, + type LmmManifest, +} from './lmmDemoConfig'; + +interface ManifestState { + manifest: LmmManifest | undefined; + error: Error | undefined; + loading: boolean; +} + +// Module-scope cache so every consumer that mounts before the first response +// shares the in-flight request, and subsequent mounts return synchronously. +let cachedManifest: LmmManifest | undefined; +let inflight: Promise | undefined; + +const fetchManifest = (): Promise => { + if (cachedManifest) { + return Promise.resolve(cachedManifest); + } + if (inflight) { + return inflight; + } + + inflight = fetch(LMM_MANIFEST_URL, { cache: 'no-store' }) + .then((r) => { + if (!r.ok) { + throw new Error( + `manifest ${r.status} from ${LMM_MANIFEST_URL}`, + ); + } + return r.json() as Promise; + }) + .then((m) => { + cachedManifest = m; + inflight = undefined; + return m; + }) + .catch((e) => { + inflight = undefined; + throw e; + }); + return inflight; +}; + +export const useLmmManifest = (): ManifestState => { + const [state, setState] = useState(() => ({ + manifest: cachedManifest, + error: undefined, + loading: !cachedManifest && LMM_DEMO_MODE, + })); + + useEffect(() => { + if (!LMM_DEMO_MODE) { + return; + } + if (cachedManifest) { + setState({ + manifest: cachedManifest, + error: undefined, + loading: false, + }); + return; + } + let cancelled = false; + fetchManifest() + .then((manifest) => { + if (!cancelled) { + setState({ manifest, error: undefined, loading: false }); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setState({ + manifest: undefined, + error: + error instanceof Error + ? error + : new Error(String(error)), + loading: false, + }); + } + }); + return () => { + cancelled = true; + }; + }, []); + + return state; +}; + +/** Synchronous read of the cached manifest (use after `useLmmManifest()` resolves). */ +export const getCachedLmmManifest = (): LmmManifest | undefined => + cachedManifest; + +/** + * True when a DAO address points at the LMM demo DAO from the cached manifest. + * Falls back to `false` when the manifest hasn't loaded yet — callers should + * gate this with `useLmmManifest()` to wait for the load. + */ +export const isLmmDemoDao = (daoAddress: string | undefined): boolean => { + if (!LMM_DEMO_MODE || !daoAddress) { + return false; + } + const m = cachedManifest; + if (!m) { + return false; + } + return daoAddress.toLowerCase() === m.lmm.dao.toLowerCase(); +}; + +/** + * Variant for components that want to react to manifest loading. + * Returns `undefined` while the manifest is still loading. + */ +export const useIsLmmDemoDao = ( + daoAddress: string | undefined, +): boolean | undefined => { + const { manifest, loading } = useLmmManifest(); + if (!LMM_DEMO_MODE) { + return false; + } + if (loading) { + return undefined; + } + if (!manifest || !daoAddress) { + return false; + } + return daoAddress.toLowerCase() === manifest.lmm.dao.toLowerCase(); +}; + +/** + * True when `policyAddress` matches the LMM demo dispatcher plugin from the + * cached manifest. Used by `FlowPolicyStructure` and the policy-detail + * header to suppress the legacy "Structure breakdown" + USDC token chip in + * favour of the rich `LmmPolicyTopology` + `StatusPanel` rendering. Returns + * `false` outside demo mode or before the manifest loads — callers that need + * to wait for the manifest should pair this with `useLmmManifest().loading`. + */ +export const useIsLmmDispatcherPolicy = ( + policyAddress: string | undefined, +): boolean => { + const { manifest } = useLmmManifest(); + if (!LMM_DEMO_MODE || !manifest || !policyAddress) { + return false; + } + return ( + policyAddress.toLowerCase() === + manifest.lmm.dispatcherPlugin.toLowerCase() + ); +}; diff --git a/src/modules/flow/dialogs/confirmDispatchDialog/confirmDispatchDialog.tsx b/src/modules/flow/dialogs/confirmDispatchDialog/confirmDispatchDialog.tsx new file mode 100644 index 0000000000..c1dbea2937 --- /dev/null +++ b/src/modules/flow/dialogs/confirmDispatchDialog/confirmDispatchDialog.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { Dialog, invariant } from '@aragon/gov-ui-kit'; +import { + type IDialogComponentProps, + useDialogContext, +} from '@/shared/components/dialogProvider'; +import { FlowAddressLabel } from '../../components/flowPrimitives/flowAddressLabel'; +import { FlowBlockieAvatar } from '../../components/flowPrimitives/flowBlockieAvatar'; +import { FlowTokenChip } from '../../components/flowPrimitives/flowTokenChip'; +import type { IFlowPolicy } from '../../types'; +import { + formatFlowAmount, + formatFlowAmountWithToken, +} from '../../utils/flowFormatters'; + +export interface IConfirmDispatchDialogParams { + policy: IFlowPolicy; + onConfirm: () => void; +} + +export interface IConfirmDispatchDialogProps + extends IDialogComponentProps {} + +export const ConfirmDispatchDialog: React.FC = ( + props, +) => { + const { location } = props; + + invariant( + location.params != null, + 'ConfirmDispatchDialog: required parameters must be set.', + ); + + const { policy, onConfirm } = location.params; + const { close } = useDialogContext(); + + const pending = policy.pending; + const topRecipients = policy.recipients.slice(0, 3); + + // `onConfirm` is responsible for transitioning the dialog stack (replacing this + // review step with the `DispatchTransactionDialog`). Calling `close()` here + // would clobber the newly opened transaction dialog. + const handleConfirm = () => { + onConfirm(); + }; + + const pendingAmount = + pending != null + ? formatFlowAmountWithToken(pending.amount, pending.token) + : formatFlowAmountWithToken(0, policy.token); + + return ( + <> + + +
    +
    + + Pending amount + +
    + + {pendingAmount} + + +
    + + {policy.strategyLong} · {policy.recipientGroup} + +
    + +
    + + Top recipients + +
      + {topRecipients.map((recipient) => ( +
    • + + + {recipient.pct != null && + pending != null && ( + + ≈{' '} + {formatFlowAmount( + pending.amount * + (recipient.pct / 100), + pending.token, + )}{' '} + {pending.token} + + )} +
    • + ))} + {policy.recipientsMore > 0 && ( +
    • + +{policy.recipientsMore} more ·{' '} + {policy.recipientGroup} +
    • + )} +
    +
    + +

    + Dispatching submits an on-chain transaction calling{' '} + dispatch() on the + policy plugin. +

    +
    +
    + close(), + }} + /> + + ); +}; diff --git a/src/modules/flow/dialogs/confirmDispatchDialog/index.ts b/src/modules/flow/dialogs/confirmDispatchDialog/index.ts new file mode 100644 index 0000000000..ee64d04173 --- /dev/null +++ b/src/modules/flow/dialogs/confirmDispatchDialog/index.ts @@ -0,0 +1,5 @@ +export { + ConfirmDispatchDialog, + type IConfirmDispatchDialogParams, + type IConfirmDispatchDialogProps, +} from './confirmDispatchDialog'; diff --git a/src/modules/flow/hooks/index.ts b/src/modules/flow/hooks/index.ts new file mode 100644 index 0000000000..46cf77100d --- /dev/null +++ b/src/modules/flow/hooks/index.ts @@ -0,0 +1,6 @@ +export { + type IUseAddAutomationActionParams, + type IUseAddAutomationActionResult, + useAddAutomationAction, +} from './useAddAutomationAction'; +export { type IUseFlowDataParams, useFlowData } from './useFlowData'; diff --git a/src/modules/flow/hooks/useAddAutomationAction.ts b/src/modules/flow/hooks/useAddAutomationAction.ts new file mode 100644 index 0000000000..bf5d86d487 --- /dev/null +++ b/src/modules/flow/hooks/useAddAutomationAction.ts @@ -0,0 +1,145 @@ +'use client'; + +/** + * Shared "Add automation" action used by the Flow overview page. Reproduces + * the exact flow kicked off from `daoSettingsPageClient.tsx` so the button on + * the Flow page lands the user in the same plugin-selection → permission-check + * → `/create/{plugin}/policy` wizard as the Settings > Automations list. + * + * Marked `'use client'` because it transitively pulls in + * `usePermissionCheckGuard` (which uses `useRef`) — the directive keeps the + * Flow module barrel importable from the Server Component layout without + * dragging a client-only hook into the RSC graph. + * + * Kept Flow-local for now (the Settings page still inlines the same logic); + * when we do the next cleanup pass we can migrate Settings to this hook too + * and retire the duplicate block there. + */ + +import { useRouter } from 'next/navigation'; +import { useCallback } from 'react'; +import { CapitalFlowDialogId } from '@/modules/capitalFlow/constants/capitalFlowDialogId'; +import type { ICreateProcessDetailsDialogParams } from '@/modules/createDao/dialogs/createProcessDetailsDialog'; +import { GovernanceDialogId } from '@/modules/governance/constants/governanceDialogId'; +import { GovernanceSlotId } from '@/modules/governance/constants/moduleSlots'; +import type { ISelectPluginDialogParams } from '@/modules/governance/dialogs/selectPluginDialog'; +import { usePermissionCheckGuard } from '@/modules/governance/hooks/usePermissionCheckGuard'; +import { type IDaoPlugin, useDao } from '@/shared/api/daoService'; +import { useDialogContext } from '@/shared/components/dialogProvider'; +import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { PluginType } from '@/shared/types'; +import { daoUtils } from '@/shared/utils/daoUtils'; + +export interface IUseAddAutomationActionParams { + /** DAO identifier used to resolve plugins, DAO metadata, and permission checks. */ + daoId: string; +} + +export interface IUseAddAutomationActionResult { + /** + * Opens the "Create policy" wizard — the same sequence the Settings page + * uses: details dialog → (optional) plugin selection → permission check → + * redirect to `/create/{plugin.address}/policy`. No-op until the DAO + * metadata has resolved. + */ + startAddAutomation: () => void; + /** + * `true` once the underlying DAO / plugin queries have resolved. Callers + * can use this to keep the trigger button disabled while we're still + * waiting on metadata to avoid a click that silently does nothing. + */ + isReady: boolean; +} + +export const useAddAutomationAction = ( + params: IUseAddAutomationActionParams, +): IUseAddAutomationActionResult => { + const { daoId } = params; + const router = useRouter(); + const { open } = useDialogContext(); + + const { data: dao } = useDao({ urlParams: { id: daoId } }); + + // Mirror the settings page: process plugins power both the proposal + // creation and the permission-guard call below. + const processPlugins = useDaoPlugins({ + daoId, + type: PluginType.PROCESS, + includeLinkedAccounts: true, + }); + + const { check: createProposalGuard } = usePermissionCheckGuard({ + permissionNamespace: 'proposal', + slotId: GovernanceSlotId.GOVERNANCE_PERMISSION_CHECK_PROPOSAL_CREATION, + plugin: processPlugins?.[0]?.meta, + daoId, + }); + + const handlePluginSelected = useCallback( + (plugin: IDaoPlugin) => { + const url = daoUtils.getDaoUrl( + dao, + `create/${plugin.address}/policy`, + ); + if (url != null) { + router.push(url); + } + }, + [dao, router], + ); + + // Defined as a ref-free recursive closure so the `onError` path (permission + // denied) can re-open the plugin selection just like the Settings page. + const handleConfirmCreate = useCallback(() => { + if (processPlugins == null || processPlugins.length === 0) { + return; + } + + const runPluginFlow = (plugin: IDaoPlugin) => { + createProposalGuard({ + plugin, + onSuccess: () => handlePluginSelected(plugin), + // Fall back to the picker on permission-check failure so the + // user can try a different plugin without restarting the wizard. + onError: () => handleConfirmCreate(), + }); + }; + + // If there's exactly one process plugin, skip the picker and go + // straight into the permission-guarded plugin-selection callback — + // same shortcut the Settings page uses. + if (processPlugins.length === 1) { + runPluginFlow(processPlugins[0].meta); + return; + } + + const selectParams: ISelectPluginDialogParams = { + daoId, + variant: 'process', + onPluginSelected: runPluginFlow, + }; + open(GovernanceDialogId.SELECT_PLUGIN, { params: selectParams }); + }, [ + createProposalGuard, + daoId, + handlePluginSelected, + open, + processPlugins, + ]); + + const startAddAutomation = useCallback(() => { + if (dao == null || processPlugins == null) { + return; + } + const detailsParams: ICreateProcessDetailsDialogParams = { + onActionClick: handleConfirmCreate, + }; + open(CapitalFlowDialogId.CREATE_POLICY_DETAILS, { + params: detailsParams, + }); + }, [dao, handleConfirmCreate, open, processPlugins]); + + const isReady = dao != null && processPlugins != null; + + return { startAddAutomation, isReady }; +}; diff --git a/src/modules/flow/hooks/useFlowData.ts b/src/modules/flow/hooks/useFlowData.ts new file mode 100644 index 0000000000..b2aa8ace86 --- /dev/null +++ b/src/modules/flow/hooks/useFlowData.ts @@ -0,0 +1,37 @@ +import { useFlowDataContext } from '../providers/flowDataProvider'; +import type { IFlowDaoData } from '../types'; + +export interface IUseFlowDataParams { + /** + * Kept for backwards compatibility with existing call sites. The provider + * owns the current DAO identity, so these arguments are ignored by the + * hook and only used as a documentation cue. + */ + network: string; + addressOrEns: string; +} + +export interface IUseFlowDataResult { + /** Live Flow snapshot — `null` while the indexer is still resolving the first query. */ + data: IFlowDaoData | null; + /** `true` on the initial load. Does not re-flip during background refetches. */ + isLoading: boolean; + /** `true` when the indexer query failed and no snapshot is available. */ + isError: boolean; +} + +/** + * Reads the current Flow data snapshot from the nearest {@link FlowDataProvider}. + * Callers should render a skeleton while `isLoading` is true and an error + * state when `isError` is true; the `data` field is only safe to read once + * it becomes non-null. + * + * Components that need to trigger mutations (e.g. "Dispatch now") should + * instead use {@link useFlowDataContext} directly. + */ +export const useFlowData = ( + _params: IUseFlowDataParams, +): IUseFlowDataResult => { + const { data, isLoading, isError } = useFlowDataContext(); + return { data, isLoading, isError }; +}; diff --git a/src/modules/flow/index.ts b/src/modules/flow/index.ts new file mode 100644 index 0000000000..834f441802 --- /dev/null +++ b/src/modules/flow/index.ts @@ -0,0 +1,13 @@ +export type { IFlowLayoutProps } from './components/flowLayout/flowLayout'; +export { FlowLayout } from './components/flowLayout/flowLayout'; +export { useFlowData } from './hooks'; +export * from './pages/flowActivityPage'; +export * from './pages/flowOverviewPage'; +export * from './pages/flowPolicyDetailPage'; +export * from './pages/flowRecipientsPage'; +export * from './pages/flowWorkbenchPage'; +export { + FlowDataProvider, + useFlowDataContext, +} from './providers/flowDataProvider'; +export * from './types'; diff --git a/src/modules/flow/pages/flowActivityPage/flowActivityPage.tsx b/src/modules/flow/pages/flowActivityPage/flowActivityPage.tsx new file mode 100644 index 0000000000..421787cc2f --- /dev/null +++ b/src/modules/flow/pages/flowActivityPage/flowActivityPage.tsx @@ -0,0 +1,17 @@ +import type { IDaoPageParams } from '@/shared/types'; +import { FlowActivityPageClient } from './flowActivityPageClient'; + +export interface IFlowActivityPageProps { + params: Promise; +} + +export const FlowActivityPage: React.FC = async ( + props, +) => { + const { params } = props; + const { network, addressOrEns } = await params; + + return ( + + ); +}; diff --git a/src/modules/flow/pages/flowActivityPage/flowActivityPageClient.tsx b/src/modules/flow/pages/flowActivityPage/flowActivityPageClient.tsx new file mode 100644 index 0000000000..306161bc44 --- /dev/null +++ b/src/modules/flow/pages/flowActivityPage/flowActivityPageClient.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { FlowActivityFeed } from '../../components/flowActivityFeed/flowActivityFeed'; +import { + FlowActivityPageSkeleton, + FlowLoadError, +} from '../../components/flowSkeletons'; +import { useFlowData } from '../../hooks'; + +export interface IFlowActivityPageClientProps { + network: string; + addressOrEns: string; +} + +export const FlowActivityPageClient: React.FC = ( + props, +) => { + const { network, addressOrEns } = props; + const { data, isError } = useFlowData({ network, addressOrEns }); + + if (data == null) { + if (isError) { + return ; + } + return ; + } + + return ( +
    +
    +

    + Activity +

    +

    + Every dispatch and lifecycle event across all policies. +

    +
    + +
    + ); +}; diff --git a/src/modules/flow/pages/flowActivityPage/index.ts b/src/modules/flow/pages/flowActivityPage/index.ts new file mode 100644 index 0000000000..371f32afe1 --- /dev/null +++ b/src/modules/flow/pages/flowActivityPage/index.ts @@ -0,0 +1,4 @@ +export { + FlowActivityPage, + type IFlowActivityPageProps, +} from './flowActivityPage'; diff --git a/src/modules/flow/pages/flowOverviewPage/flowOverviewPage.tsx b/src/modules/flow/pages/flowOverviewPage/flowOverviewPage.tsx new file mode 100644 index 0000000000..840f660026 --- /dev/null +++ b/src/modules/flow/pages/flowOverviewPage/flowOverviewPage.tsx @@ -0,0 +1,17 @@ +import type { IDaoPageParams } from '@/shared/types'; +import { FlowOverviewPageClient } from './flowOverviewPageClient'; + +export interface IFlowOverviewPageProps { + params: Promise; +} + +export const FlowOverviewPage: React.FC = async ( + props, +) => { + const { params } = props; + const { network, addressOrEns } = await params; + + return ( + + ); +}; diff --git a/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx b/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx new file mode 100644 index 0000000000..563f5564ba --- /dev/null +++ b/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { FlowActivityFeed } from '../../components/flowActivityFeed/flowActivityFeed'; +import { FlowKpiRow } from '../../components/flowKpiRow/flowKpiRow'; +import { FlowLede } from '../../components/flowLede/flowLede'; +import { FlowOrchestratorsSection } from '../../components/flowOrchestrators'; +import { FlowPoliciesSection } from '../../components/flowPoliciesSection'; +import { + FlowLoadError, + FlowOverviewPageSkeleton, +} from '../../components/flowSkeletons'; +import { LmmDemoBanner } from '../../demo/LmmDemoBanner'; +import { useFlowData } from '../../hooks'; + +export interface IFlowOverviewPageClientProps { + network: string; + addressOrEns: string; +} + +export const FlowOverviewPageClient: React.FC = ( + props, +) => { + const { network, addressOrEns } = props; + const { data, isError } = useFlowData({ network, addressOrEns }); + + if (data == null) { + if (isError) { + return ; + } + return ; + } + + return ( +
    + + + + + + + + + +
    + ); +}; diff --git a/src/modules/flow/pages/flowOverviewPage/index.ts b/src/modules/flow/pages/flowOverviewPage/index.ts new file mode 100644 index 0000000000..8df89e513c --- /dev/null +++ b/src/modules/flow/pages/flowOverviewPage/index.ts @@ -0,0 +1,4 @@ +export { + FlowOverviewPage, + type IFlowOverviewPageProps, +} from './flowOverviewPage'; diff --git a/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPage.tsx b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPage.tsx new file mode 100644 index 0000000000..5ef6cf23a8 --- /dev/null +++ b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPage.tsx @@ -0,0 +1,25 @@ +import type { IDaoPageParams } from '@/shared/types'; +import { FlowPolicyDetailPageClient } from './flowPolicyDetailPageClient'; + +export interface IFlowPolicyDetailPageParams extends IDaoPageParams { + id: string; +} + +export interface IFlowPolicyDetailPageProps { + params: Promise; +} + +export const FlowPolicyDetailPage: React.FC< + IFlowPolicyDetailPageProps +> = async (props) => { + const { params } = props; + const { network, addressOrEns, id } = await params; + + return ( + + ); +}; diff --git a/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx new file mode 100644 index 0000000000..0915e6a123 --- /dev/null +++ b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx @@ -0,0 +1,361 @@ +'use client'; + +import { Button } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { useDialogContext } from '@/shared/components/dialogProvider'; +import { FlowPolicyChart } from '../../components/flowPolicyChart/flowPolicyChart'; +import { FlowPolicyHistory } from '../../components/flowPolicyHistory/flowPolicyHistory'; +import { FlowPolicyStructure } from '../../components/flowPolicyStructure/flowPolicyStructure'; +import { + FlowStatusDot, + FlowStrategyChip, + FlowTokenChip, + FlowWaitingForIndexerBadge, +} from '../../components/flowPrimitives'; +import { FlowRecipientsTable } from '../../components/flowRecipientsTable/flowRecipientsTable'; +import { + FlowLoadError, + FlowPolicyDetailPageSkeleton, +} from '../../components/flowSkeletons'; +// LMM_DEMO_HACK: the policy header in demo mode exposes a "More actions ▾" +// dropdown that wraps the Anvil cheat actions (warp time, top up balances, +// etc). Renders to `null` outside demo mode. +import { LmmCheatsMenu } from '../../components/lidoMoneyMachine/LmmCheatsMenu'; +import { FlowDialogId } from '../../constants/flowDialogId'; +import { LmmDemoBanner } from '../../demo/LmmDemoBanner'; +import { LMM_DEMO_MODE } from '../../demo/lmmDemoConfig'; +import { useIsLmmDispatcherPolicy } from '../../demo/useLmmManifest'; +import type { IConfirmDispatchDialogParams } from '../../dialogs/confirmDispatchDialog'; +import { useFlowDataContext } from '../../providers/flowDataProvider'; +import type { IFlowPolicy } from '../../types'; +import { + formatFlowAmount, + formatRelative, + formatShortDate, + isDispatchableStrategy, +} from '../../utils/flowFormatters'; + +export interface IFlowPolicyDetailPageClientProps { + network: string; + addressOrEns: string; + policyId: string; +} + +export const FlowPolicyDetailPageClient: React.FC< + IFlowPolicyDetailPageClientProps +> = (props) => { + const { network, addressOrEns, policyId } = props; + const { data, dispatchPolicy, getPendingDispatch, isError, chainNowMs } = + useFlowDataContext(); + const { open } = useDialogContext(); + const decodedPolicyId = decodeURIComponent(policyId); + const base = `/dao/${network}/${addressOrEns}/flow`; + + // LMM_DEMO_HACK: chip clicks on the dashboard orchestrator card deep-link + // here with `?node=`. Pulled at this level so we can + // both pass it down to `FlowPolicyStructure` (auto-selects the topology + // node) AND keep server/client rendering symmetric on hard navigations. + const searchParams = useSearchParams(); + const selectedNodeAddress = searchParams?.get('node') ?? undefined; + + // Orchestrators (multi-dispatch dispatchers) live alongside leaf policies in + // `data.orchestratorPolicies` rather than `data.policies` so KPI counters + // and the "Policies" overview pill don't double-count them with the + // dedicated `FlowMultiDispatchCard`. The detail route falls back to that + // mirror list so a click on the orchestrator card resolves cleanly. + // Resolved BEFORE the early returns so the `useIsLmmDispatcherPolicy` + // hook below sees a stable `policy?.address` on every render. + const matchById = (p: IFlowPolicy) => + p.id === decodedPolicyId || p.id === policyId; + const policy = data + ? (data.policies.find(matchById) ?? + data.orchestratorPolicies.find(matchById)) + : undefined; + const isLmmDispatcher = useIsLmmDispatcherPolicy(policy?.address); + + if (data == null) { + if (isError) { + return ; + } + return ; + } + + if (policy == null) { + return ( +
    +

    + Policy not found +

    +

    + No policy with id {decodedPolicyId} is + installed on this DAO. +

    + + ← Back to overview + +
    + ); + } + + const handleDispatchClick = () => { + // LMM_DEMO_HACK: the dispatcher policy doesn't have a meaningful + // single-token "pending amount" or recipient list — the dispatch + // fans out into 3 strategy legs against different tokens. The + // generic `ConfirmDispatchDialog` was misrepresenting it as "0 + // USDC · No recipients yet"; for the demo we skip straight to the + // LMM-specific `LmmDemoDispatchDialog` (rendered from + // `dispatchPolicy` → `DispatchTransactionDialog`), which already + // shows the simulator's per-leg breakdown. + if (isLmmDispatcher) { + dispatchPolicy(policy.id); + return; + } + const params: IConfirmDispatchDialogParams = { + policy, + onConfirm: () => dispatchPolicy(policy.id), + }; + open(FlowDialogId.CONFIRM_DISPATCH, { params }); + }; + + const dispatchable = + isDispatchableStrategy(policy.strategy) && policy.status !== 'paused'; + const pendingDispatch = getPendingDispatch(policy.id); + const isDispatching = pendingDispatch != null; + // Mirrors FlowPolicyCard: we allow manual dispatch for every non-claimer, + // non-archived router regardless of whether it's already "live" — the only + // hard gates are a live cooldown and an in-flight tx we haven't confirmed. + // LMM_DEMO_HACK: `chainNowMs` overrides Date.now() when the anvil fork + // has been warped via the cheats menu, so warping past the cooldown + // actually unlocks the Dispatch button — see useLmmChainNow. + const nowMs = chainNowMs ?? Date.now(); + const cooldownActive = + policy.status === 'cooldown' && + policy.cooldown != null && + new Date(policy.cooldown.readyAt).getTime() > nowMs; + const dispatchDisabled = cooldownActive || isDispatching; + const dispatchLabel = isDispatching + ? 'Dispatching…' + : policy.pending != null + ? `Dispatch now · ${formatFlowAmount(policy.pending.amount, policy.pending.token)} ${policy.pending.token}` + : 'Dispatch now'; + + return ( +
    + +
    + {/* Stack vertically below `md` so mobile gets the title block + * first then the CTA underneath. On `md+` switch to a row + * with `flex-1` left (claims all remaining space and clamps + * long descriptions with `min-w-0`) and a 260px right rail + * (`shrink-0` so it never collapses or wraps below). */} +
    +
    +
    +

    + {policy.name} +

    + + {/* LMM_DEMO_HACK: the dispatcher policy has no + * single "token" — its three legs touch stETH, + * wstETH, LDO and (mock) USDC. `policy.token` + * fallbacks to `'USDC'` for never-dispatched + * multi-dispatch policies (see envioFlowMapper.ts + * → `policyToken`), which is misleading. Skip + * the chip for the LMM dispatcher and let the + * strategy chip carry the headline instead. */} + {!isLmmDispatcher && ( + + )} +
    +

    + {policy.description} +

    + {/* Keep the metadata row focused: current status, when + * the policy was installed (and via which proposal), + * and the edit link. Full lifecycle lives in the + * `FlowPolicyHistory` section below, so no compact + * ribbon here. */} +
    + + + + Edit ↗ + +
    +
    + {/* Right rail: primary CTA + supporting copy + (demo only) + * cheats menu. `md:shrink-0` keeps the rail at exactly + * 260px on desktop (never wraps below the title block); + * on mobile it spans full width under the header. */} +
    + {policy.status === 'paused' ? ( + + {policy.uninstalledAt + ? `Archived · uninstalled ${formatRelative(policy.uninstalledAt)}` + : 'Archived · uninstalled'} + + ) : dispatchable ? ( + <> + + + {dispatchDisabled + ? dispatchDisabledReason(policy, nowMs) + : 'Dispatches the pending amount to all recipients.'} + + + ) : ( + <> + + {policy.pending != null + ? `${formatFlowAmount(policy.pending.amount, policy.pending.token)} ${policy.pending.token} claimable` + : 'Claim window open'} + + + Pull-based policy — recipients claim + directly, no manual dispatch required. + + + )} + {/* LMM_DEMO_HACK: the cheats menu used to live in + * the metadata-pill row (status / installed / edit). + * It crowded that row to three wrapping lines in the + * LMM demo — moved here so it sits as a secondary + * action below the primary Dispatch CTA. */} + {LMM_DEMO_MODE && ( +
    + +
    + )} +
    +
    + {pendingDispatch != null && ( + + )} +
    + + + + + +
    + ); +}; + +const dispatchDisabledReason = (policy: IFlowPolicy, nowMs: number): string => { + if (policy.status === 'cooldown') { + return `Cooldown · ready ${policy.cooldown != null ? formatRelative(policy.cooldown.readyAt, nowMs) : ''}.`; + } + if (policy.status === 'paused') { + return 'Paused for review. Resume via a governance proposal.'; + } + if (policy.status === 'awaiting') { + return 'Awaiting activation proposal.'; + } + return policy.nextDispatchLabel; +}; + +const StatusBadge: React.FC<{ policy: IFlowPolicy }> = ({ policy }) => { + const toneByStatus: Record = { + ready: 'bg-primary-100 text-primary-800', + // Neutralised — was success-100/text-success-800. + live: 'bg-neutral-100 text-neutral-700', + cooldown: 'bg-neutral-100 text-neutral-700', + awaiting: 'bg-neutral-100 text-neutral-700', + paused: 'bg-warning-100 text-warning-800', + never: 'bg-neutral-100 text-neutral-500', + }; + return ( + + + {policy.statusLabel} + + ); +}; + +/** + * "Installed {date} via {proposal}" chip. Uses the proposal title when the + * REST join resolved it, otherwise falls back to the shorter slug / id (or a + * plain "Installed {date}" when we have no attribution at all). Linkified + * whenever we know the proposal slug so the user can jump to it. + */ +const InstalledViaPill: React.FC<{ + policy: IFlowPolicy; + network: string; + addressOrEns: string; +}> = ({ policy, network, addressOrEns }) => { + const proposalLabel = + policy.installedViaProposalTitle ?? + (policy.installedViaProposal !== '—' + ? policy.installedViaProposal + : undefined); + const proposalHref = policy.installedViaProposalSlug + ? `/dao/${network}/${addressOrEns}/proposals/${policy.installedViaProposalSlug}` + : null; + + if (proposalHref) { + return ( + + Installed {formatShortDate(policy.createdAt)} + {proposalLabel ? <> · {proposalLabel} → : <> →} + + ); + } + + return ( + + Installed {formatShortDate(policy.createdAt)} + {proposalLabel ? ` · ${proposalLabel}` : ''} + + ); +}; diff --git a/src/modules/flow/pages/flowPolicyDetailPage/index.ts b/src/modules/flow/pages/flowPolicyDetailPage/index.ts new file mode 100644 index 0000000000..ca93800dcc --- /dev/null +++ b/src/modules/flow/pages/flowPolicyDetailPage/index.ts @@ -0,0 +1,4 @@ +export { + FlowPolicyDetailPage, + type IFlowPolicyDetailPageProps, +} from './flowPolicyDetailPage'; diff --git a/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPage.tsx b/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPage.tsx new file mode 100644 index 0000000000..5935eebc1c --- /dev/null +++ b/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPage.tsx @@ -0,0 +1,20 @@ +import type { IDaoPageParams } from '@/shared/types'; +import { FlowRecipientsPageClient } from './flowRecipientsPageClient'; + +export interface IFlowRecipientsPageProps { + params: Promise; +} + +export const FlowRecipientsPage: React.FC = async ( + props, +) => { + const { params } = props; + const { network, addressOrEns } = await params; + + return ( + + ); +}; diff --git a/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPageClient.tsx b/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPageClient.tsx new file mode 100644 index 0000000000..d2e7d92955 --- /dev/null +++ b/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPageClient.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { FlowRecipientsTable } from '../../components/flowRecipientsTable/flowRecipientsTable'; +import { + FlowLoadError, + FlowRecipientsPageSkeleton, +} from '../../components/flowSkeletons'; +import { useFlowData } from '../../hooks'; + +export interface IFlowRecipientsPageClientProps { + network: string; + addressOrEns: string; +} + +export const FlowRecipientsPageClient: React.FC< + IFlowRecipientsPageClientProps +> = (props) => { + const { network, addressOrEns } = props; + const { data, isError } = useFlowData({ network, addressOrEns }); + + if (data == null) { + if (isError) { + return ; + } + return ; + } + + return ( +
    +
    +

    + Recipients +

    +

    + Addresses receiving value from any active automation. +

    +
    + +
    + ); +}; diff --git a/src/modules/flow/pages/flowRecipientsPage/index.ts b/src/modules/flow/pages/flowRecipientsPage/index.ts new file mode 100644 index 0000000000..87eebded93 --- /dev/null +++ b/src/modules/flow/pages/flowRecipientsPage/index.ts @@ -0,0 +1,4 @@ +export { + FlowRecipientsPage, + type IFlowRecipientsPageProps, +} from './flowRecipientsPage'; diff --git a/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPage.tsx b/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPage.tsx new file mode 100644 index 0000000000..4588f65a4c --- /dev/null +++ b/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPage.tsx @@ -0,0 +1,20 @@ +import type { IDaoPageParams } from '@/shared/types'; +import { FlowWorkbenchPageClient } from './flowWorkbenchPageClient'; + +export interface IFlowWorkbenchPageProps { + params: Promise; +} + +export const FlowWorkbenchPage: React.FC = async ( + props, +) => { + const { params } = props; + const { network, addressOrEns } = await params; + + return ( + + ); +}; diff --git a/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPageClient.tsx b/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPageClient.tsx new file mode 100644 index 0000000000..210b13d65c --- /dev/null +++ b/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPageClient.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { EmptyState } from '@aragon/gov-ui-kit'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useMemo } from 'react'; +import { + buildFlowGraph, + MEANINGFUL_AMOUNT_EPS, +} from '../../canvas/buildFlowGraph'; +import { toFlowMachineDescriptor } from '../../canvas/flowDescriptor'; +import { toLiveDynamics } from '../../canvas/flowDynamics'; +import { toIndexedDynamics } from '../../canvas/toIndexedDynamics'; +import { FlowLoadError } from '../../components/flowSkeletons'; +import { + buildFlowDescription, + buildFlowOptions, + buildHistory, + buildNextRun, + buildRecipientHint, + buildStats, + toRunDynamics, +} from '../../components/flowWorkbench/buildWorkbenchModel'; +import { FlowFocus } from '../../components/flowWorkbench/flowFocus'; +import type { IWorkbenchModel } from '../../components/flowWorkbench/workbenchModel'; +import { strategyParamsByAddress } from '../../demo/strategyParams'; +import { useLmmManifest } from '../../demo/useLmmManifest'; +import { useFlowDataContext } from '../../providers/flowDataProvider'; +import { buildFlowAddressBook } from '../../utils/flowAddressBook'; + +export interface IFlowWorkbenchPageClientProps { + network: string; + addressOrEns: string; +} + +export const FlowWorkbenchPageClient: React.FC< + IFlowWorkbenchPageClientProps +> = () => { + const { data, isError, liveSnapshot, now, dispatchPolicy } = + useFlowDataContext(); + const { manifest } = useLmmManifest(); + // Operational DAO vault (holds budgets, runs strategies). Outputs landing at + // any other address (e.g. the UniV2 LP → Lido Agent) are shown as external + // recipients rather than looped back to the vault. + const daoAddress = manifest?.lmm?.dao; + + const router = useRouter(); + const pathname = usePathname() ?? ''; + const searchParams = useSearchParams(); + + const flowParam = searchParams?.get('flow') ?? null; + const runParam = searchParams?.get('run') ?? null; + + const setParams = useCallback( + (next: { flow?: string | null; run?: string | null }) => { + const params = new URLSearchParams(searchParams?.toString() ?? ''); + for (const [key, value] of Object.entries(next)) { + if (value == null) { + params.delete(key); + } else { + params.set(key, value); + } + } + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { + scroll: false, + }); + }, + [router, pathname, searchParams], + ); + + const orchestrators = data?.orchestrators ?? []; + const selectedFlowId = flowParam ?? orchestrators[0]?.id ?? null; + const orchestrator = useMemo( + () => orchestrators.find((o) => o.id === selectedFlowId) ?? null, + [orchestrators, selectedFlowId], + ); + + const recipient = useMemo(() => { + if (!(data && orchestrator)) { + return undefined; + } + // The external recipient is whatever address a leg's output lands at that + // isn't the operational vault (here: the UniV2 LP → Lido Agent). Derived + // from the indexed edges (data), labelled via the shared address book — + // no per-flow hardcode; proceeds that loop back to the vault aren't this. + const book = buildFlowAddressBook( + daoAddress + ? { dao: { address: daoAddress, name: data.dao.name } } + : {}, + ); + const dao = daoAddress?.toLowerCase(); + for (const step of orchestrator.latestIndexedSteps ?? []) { + for (const edge of step.edges) { + const external = + (edge.role === 'vaultIn' || edge.role === 'external') && + edge.to != null && + (dao == null || edge.to.toLowerCase() !== dao); + if (external && edge.to) { + return { + address: edge.to, + label: + book.resolve(edge.to)?.label ?? + `${edge.to.slice(0, 6)}…${edge.to.slice(-4)}`, + }; + } + } + } + return buildRecipientHint(data); + }, [data, orchestrator, daoAddress]); + + // Strategy config params (slippage, target token, oracle staleness) live + // only on the inspected topology, not in the indexer taxonomy. Project them + // to display pairs keyed by strategy address so the descriptor can carry + // them onto each step. Empty outside demo mode (no topology). + const strategyParams = useMemo( + () => strategyParamsByAddress(liveSnapshot?.topology ?? null), + [liveSnapshot?.topology], + ); + + const descriptor = useMemo(() => { + if (!(data && orchestrator)) { + return null; + } + return toFlowMachineDescriptor(orchestrator, { + sourceLabel: `${data.dao.name} Vault`, + recipient: recipient + ? { address: recipient.address ?? '', label: recipient.label } + : undefined, + paramsByAddress: strategyParams, + }); + }, [data, orchestrator, recipient, strategyParams]); + + // Live RPC overlay applies generically by address — only when the live + // snapshot's dispatcher is the selected orchestrator. Powers the forward + // "next dispatch" summary + the budget/gate/epoch sub-node readings. + const liveDynamics = useMemo(() => { + const snapshot = liveSnapshot?.snapshot; + if (!(descriptor && snapshot && orchestrator)) { + return null; + } + const matches = + liveSnapshot?.dispatcherAddress?.toLowerCase() === + orchestrator.address.toLowerCase(); + return matches ? toLiveDynamics({ descriptor, snapshot }) : null; + }, [descriptor, liveSnapshot, orchestrator]); + + // Default canvas dynamics = the UPCOMING dispatch. We show what the next + // dispatch will move (live RPC simulation: amounts + budget/gate/epoch + // readings + balances + would-fire state), NOT the last settled run — the + // previous-run amounts read as misleading next to a live "Firing"/budget. + // History/replay (the `?run=` path below) is where past runs are shown. + // Falls back to the latest indexed run only when there is no live snapshot. + const overviewDynamics = useMemo(() => { + if (liveDynamics) { + return liveDynamics; + } + const indexedSteps = orchestrator?.latestIndexedSteps ?? []; + if (indexedSteps.length === 0) { + return null; + } + return toIndexedDynamics({ + steps: indexedSteps, + runId: null, + daoAddress, + }); + }, [orchestrator, liveDynamics, daoAddress]); + + const activeRun = useMemo(() => { + if (!(orchestrator && runParam)) { + return null; + } + return orchestrator.runs.some((r) => r.id === runParam) + ? runParam + : null; + }, [orchestrator, runParam]); + + const graph = useMemo(() => { + if (!descriptor) { + return null; + } + if (activeRun != null) { + const run = + orchestrator?.runs.find((r) => r.id === activeRun) ?? + orchestrator?.runs[0]; + // Prefer the indexer's provenance steps for the run; fall back to + // the legacy leg projection for runs without indexed data. + const replay = run?.indexedSteps?.length + ? toIndexedDynamics({ + steps: run.indexedSteps, + runId: run.id, + daoAddress, + }) + : run + ? toRunDynamics(descriptor, run) + : null; + return buildFlowGraph({ descriptor, dynamics: replay }); + } + return buildFlowGraph({ descriptor, dynamics: overviewDynamics }); + }, [descriptor, activeRun, overviewDynamics, orchestrator, daoAddress]); + + const model = useMemo(() => { + if (!data) { + return null; + } + return { + dao: data.dao.name, + flows: buildFlowOptions(data), + selectedFlowId, + stats: orchestrator ? buildStats(orchestrator, now) : null, + nextRun: + descriptor && liveDynamics + ? buildNextRun(descriptor, liveDynamics) + : null, + history: orchestrator ? buildHistory(orchestrator, now) : [], + isLive: liveDynamics != null, + }; + }, [data, selectedFlowId, orchestrator, descriptor, liveDynamics, now]); + + const description = useMemo( + () => (orchestrator ? buildFlowDescription(orchestrator) : undefined), + [orchestrator], + ); + + // Honest dispatch affordance: enable only when the live simulation says at + // least one leg would actually MOVE a meaningful amount. The simulator + // already encodes every strategy gate (paused, gate closed, oracle stale, + // same-epoch slot burned, zero budget) → a gated leg isn't `willFire`; on + // top of that we drop dust (wrapping 1 wei of leftover stETH "fires" but + // moves nothing). With no live snapshot we can't predict, so leave enabled. + const moves = (a: number | null): boolean => + a == null || Math.abs(a) > MEANINGFUL_AMOUNT_EPS; + const dispatchMoves = (s: { + willFire: boolean; + ins: { amount: number | null }[]; + outs: { amount: number | null }[]; + }) => + s.willFire && + (s.ins.some((i) => moves(i.amount)) || + s.outs.some((o) => moves(o.amount))); + const canDispatch = + orchestrator != null && + (model?.nextRun == null || model.nextRun.steps.some(dispatchMoves)); + const dispatchReason = canDispatch + ? undefined + : 'Nothing to dispatch right now — balances are empty, the epoch slot is already used, or a gate is closed. Top up, warp an epoch, or open the gate.'; + + const onDispatch = useCallback(() => { + if (orchestrator) { + dispatchPolicy(orchestrator.id); + } + }, [orchestrator, dispatchPolicy]); + + if (data == null) { + if (isError) { + return ; + } + return ; + } + + if (orchestrators.length === 0 || model == null) { + return ( +
    + +
    + ); + } + + return ( + setParams({ run: null })} + onDispatch={onDispatch} + onReplay={(run) => setParams({ run })} + onSelectFlow={(id) => setParams({ flow: id, run: null })} + recipient={recipient} + /> + ); +}; + +const WorkbenchLoading: React.FC = () => ( +
    +
    +
    +); diff --git a/src/modules/flow/pages/flowWorkbenchPage/index.ts b/src/modules/flow/pages/flowWorkbenchPage/index.ts new file mode 100644 index 0000000000..4fa65ca7be --- /dev/null +++ b/src/modules/flow/pages/flowWorkbenchPage/index.ts @@ -0,0 +1,2 @@ +export type { IFlowWorkbenchPageProps } from './flowWorkbenchPage'; +export { FlowWorkbenchPage } from './flowWorkbenchPage'; diff --git a/src/modules/flow/providers/flowDataProvider.tsx b/src/modules/flow/providers/flowDataProvider.tsx new file mode 100644 index 0000000000..8713f3d329 --- /dev/null +++ b/src/modules/flow/providers/flowDataProvider.tsx @@ -0,0 +1,550 @@ +'use client'; + +import { useQueryClient } from '@tanstack/react-query'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useConnectedWalletGuard } from '@/modules/application/hooks/useConnectedWalletGuard'; +import { CapitalFlowDialogId } from '@/modules/capitalFlow/constants/capitalFlowDialogId'; +import type { + IDispatchSimulationDialogParams, + IDispatchTransactionDialogParams, +} from '@/modules/capitalFlow/dialogs/dispatchDialog'; +import type { Network } from '@/shared/api/daoService'; +import { useDialogContext } from '@/shared/components/dialogProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +// LMM_DEMO_HACK: skip the Tenderly review step + wallet-connect guard for +// dispatches against the LMM demo DAO — those go to a local Anvil fork via +// the demo dispatch dialog and have their own (lido/preview) simulator, so +// the production Tenderly call would just fail / mislead the user. +import { LMM_DEMO_MODE } from '../demo/lmmDemoConfig'; +import { useLmmChainNow } from '../demo/useLmmChainNow'; +import { + type IUseLmmLiveSnapshotResult, + useLmmLiveSnapshot, +} from '../demo/useLmmLiveSnapshot'; +import { isLmmDemoDao } from '../demo/useLmmManifest'; +import type { IFlowDaoData } from '../types'; +import { useEnvioFlowData } from './useEnvioFlowData'; + +const TOAST_DURATION_MS = 4000; +/** + * If the indexer hasn't surfaced a dispatch within this window we give up on the + * optimistic "pending" state and tell the user to refresh manually — otherwise the + * badge could sit forever if Envio fell behind. + */ +const PENDING_DISPATCH_TIMEOUT_MS = 60_000; + +export interface IFlowToast { + id: string; + tone: 'success' | 'info' | 'error' | 'warning'; + title: string; + description?: string; +} + +/** + * One entry per "in-flight" dispatch: the user signed the tx, the wallet returned a + * hash, but the indexer hasn't yet produced an execution we can render. We keep this + * outside `data` so the Envio snapshot stays the source of truth for everything else. + */ +export interface IFlowPendingDispatch { + policyId: string; + policyAddress: string; + policyName: string; + txHash: string; + startedAt: number; +} + +export interface IFlowDataContext { + /** + * DAO identifier the provider was mounted with — exposed so deeper + * components (e.g. the "Add automation" button on the overview page) can + * trigger DAO-scoped actions without threading props through every + * section. + */ + daoId: string; + /** + * Live Envio-backed snapshot, or `null` while the first query is still in + * flight / has errored. Page clients should render a skeleton / error + * state when this is `null` and defer rendering data-coupled UI until a + * non-null snapshot is available. + */ + data: IFlowDaoData | null; + /** + * `true` while the Envio-backed snapshot is still being resolved for the + * very first time. Flips to `false` as soon as `data` becomes non-null + * (further background refetches do not toggle this flag). + */ + isLoading: boolean; + /** `true` when the Envio query failed and we have no snapshot to fall back on. */ + isError: boolean; + /** + * Broadcasts a real on-chain `dispatch()` transaction via the shared + * `DispatchTransactionDialog`. The flow page's `ConfirmDispatchDialog` calls + * this after the user reviews the pending amount + recipients. + * + * Intended flow: + * 1. Close the review dialog (dialog stack replace). + * 2. User signs the tx in their wallet. + * 3. Once a receipt arrives we push an entry onto `pendingDispatches` and + * flip the indexer to urgent polling — the UI shows a "waiting for + * indexer" badge until a matching `txHash` appears in Envio. + */ + dispatchPolicy: (policyId: string) => void; + /** All in-flight dispatches, keyed by policy id. */ + pendingDispatches: IFlowPendingDispatch[]; + /** + * Convenience lookup used by cards / detail pages to decide whether to render + * the "waiting for indexer" badge. Returns the pending entry if the policy has + * an unresolved dispatch, otherwise `undefined`. + */ + getPendingDispatch: (policyId: string) => IFlowPendingDispatch | undefined; + toasts: IFlowToast[]; + dismissToast: (id: string) => void; + /** + * LMM demo only: latest known anvil HEAD block timestamp in ms. + * `undefined` outside demo mode, before the first poll, or when the + * fork's clock is within ~1m of the host wall-clock (no warp applied). + * Consumers comparing against `cooldown.readyAt` should fall back to + * `Date.now()` on undefined. See `useLmmChainNow` for the rationale. + */ + chainNowMs?: number; + /** + * "Effective now" in ms. Equals `chainNowMs` when the LMM fork is + * warped, `Date.now()` everywhere else. Always defined — consumers + * should prefer this over reading `chainNowMs` directly so they + * stay correct outside demo mode. + */ + now: number; + /** + * LMM demo only: live RPC snapshot of the orchestrator's per-leg + * budget/gate/cowSwap/simulate state. `null` outside demo mode or + * while the first read is in flight. See `useLmmLiveSnapshot`. + */ + liveSnapshot: IUseLmmLiveSnapshotResult | null; +} + +const FlowDataContext = createContext(null); + +export interface IFlowDataProviderProps { + network: string; + addressOrEns: string; + /** + * DAO identifier (e.g. `ethereum-sepolia-0xabc…`) — required so the + * provider can resolve DAO metadata + linked accounts via the REST API. + * Consumers render a skeleton while the Envio snapshot is loading and an + * error state when the indexer query fails. + */ + daoId?: string; + children?: ReactNode; +} + +export const FlowDataProvider: React.FC = (props) => { + const { network, addressOrEns, daoId, children } = props; + + const [pendingDispatches, setPendingDispatches] = useState< + IFlowPendingDispatch[] + >([]); + + const envioResult = useEnvioFlowData({ + network, + addressOrEns, + daoId: daoId ?? '', + isUrgent: pendingDispatches.length > 0, + }); + + // LMM_DEMO_HACK: see useLmmChainNow — overrides Date.now() for cooldown + // gating when the anvil fork has been warped via the cheats menu. + const { chainNowMs } = useLmmChainNow(); + + // LMM_DEMO_HACK: lift the orchestrator's live RPC snapshot to the + // provider so KPI / Activity / Topology / Detail share one inspect() + // result instead of each mounting their own. Returns EMPTY outside + // demo mode — non-demo consumers should never see a non-null + // `liveSnapshot.snapshot`. + const liveSnapshot = useLmmLiveSnapshot(); + + // Cache the last non-null snapshot so the UI doesn't flicker back to a + // skeleton while a background refetch is in flight. `envioResult.data` + // briefly goes undefined on some refetch transitions — pinning the last + // good snapshot keeps pages stable until the new one arrives. + const [data, setData] = useState(null); + const [toasts, setToasts] = useState([]); + + const queryClient = useQueryClient(); + const { open } = useDialogContext(); + const { check: checkWalletConnected } = useConnectedWalletGuard(); + + useEffect(() => { + if (envioResult.data) { + setData(envioResult.data); + } + }, [envioResult.data]); + + // Reset the snapshot whenever the DAO identity changes so we don't bleed + // stale data across routes (e.g. navigating between two DAOs). The + // concatenated key keeps the dep list compact while still re-running the + // reset any time the caller swaps DAO. + const daoIdentityKey = `${network}:${addressOrEns}:${daoId ?? ''}`; + // biome-ignore lint/correctness/useExhaustiveDependencies: reset is keyed on daoIdentityKey by design + useEffect(() => { + setData(null); + }, [daoIdentityKey]); + + useEffect(() => { + if (envioResult.isError) { + // biome-ignore lint/suspicious/noConsole: surface Envio query failures to aid debugging + console.warn( + '[FlowDataProvider] Envio query failed.', + envioResult.error, + ); + } + }, [envioResult.isError, envioResult.error]); + + const pushToast = useCallback((toast: Omit) => { + const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const entry: IFlowToast = { id, ...toast }; + setToasts((current) => [...current, entry]); + setTimeout(() => { + setToasts((current) => current.filter((t) => t.id !== id)); + }, TOAST_DURATION_MS); + }, []); + + const dismissToast = useCallback((id: string) => { + setToasts((current) => current.filter((t) => t.id !== id)); + }, []); + + /** + * Keep the latest rest policies + network in a ref so we can read them from the + * `dispatchPolicy` callback without invalidating its identity every time the + * REST query refetches. + */ + const restPoliciesRef = useRef(envioResult.restPolicies); + useEffect(() => { + restPoliciesRef.current = envioResult.restPolicies; + }, [envioResult.restPolicies]); + + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + const indexerQueryKey = envioResult.indexerQueryKey; + const indexerQueryKeyRef = useRef(indexerQueryKey); + useEffect(() => { + indexerQueryKeyRef.current = indexerQueryKey; + }, [indexerQueryKey]); + + const markDispatchPending = useCallback( + (entry: Omit) => { + const startedAt = Date.now(); + setPendingDispatches((current) => { + const deduped = current.filter( + (p) => + p.txHash.toLowerCase() !== entry.txHash.toLowerCase(), + ); + return [...deduped, { ...entry, startedAt }]; + }); + + // Kick the indexer ahead of the next urgent tick so there's no perceived + // lag between "tx confirmed in wallet" and "waiting for indexer" badge. + if (indexerQueryKeyRef.current) { + void queryClient.invalidateQueries({ + queryKey: indexerQueryKeyRef.current, + }); + } + + pushToast({ + tone: 'info', + title: 'Dispatch submitted', + description: `${entry.policyName} — waiting for the indexer to pick it up.`, + }); + }, + [pushToast, queryClient], + ); + + const dispatchPolicy = useCallback( + (policyId: string) => { + const currentData = dataRef.current; + if (currentData == null) { + pushToast({ + tone: 'error', + title: 'Flow data still loading', + description: + 'Wait for the indexer snapshot to load and try again.', + }); + return; + } + // Orchestrators live on their own list but, on-chain, a multi-dispatch + // plugin exposes the same `dispatch()` entrypoint as a leaf router, + // so the same transaction dialog works for both. Resolve leaf first, + // then fall back to orchestrator — ids are unique across both lists. + const flowPolicy = + currentData.policies.find((p) => p.id === policyId) ?? + currentData.orchestrators.find((o) => o.id === policyId); + + if (flowPolicy == null) { + pushToast({ + tone: 'error', + title: 'Policy not available', + description: + 'Refresh the page and try again — the indexer snapshot is out of sync.', + }); + return; + } + + const restPolicies = restPoliciesRef.current; + const matchingRestPolicy = restPolicies.find( + (p) => + p.address.toLowerCase() === + flowPolicy.address.toLowerCase(), + ); + + if (matchingRestPolicy == null) { + pushToast({ + tone: 'error', + title: 'Dispatch unavailable', + description: + 'Policy metadata is still indexing. Try again in a minute.', + }); + return; + } + + const typedNetwork = network as Network; + const onDispatchSuccess: IDispatchTransactionDialogParams['onDispatchSuccess'] = + ({ txHash }) => { + markDispatchPending({ + policyId: flowPolicy.id, + policyAddress: flowPolicy.address, + policyName: flowPolicy.name, + txHash, + }); + }; + + // Mirror the `DispatchPanel` (transactions page) route so manual + // Flow dispatches go through the same review → simulate → sign + // wizard: show the Tenderly simulation first when the network + // supports it, and fall back to opening the transaction dialog + // directly on networks where Tenderly isn't wired in. + const supportsTenderly = + networkDefinitions[typedNetwork]?.tenderlySupport ?? false; + + // LMM demo dispatches go to a private Anvil fork (no real chain + // RPC), so: + // - Tenderly can't see those contracts → skip the review step. + // - The wallet-connect guard is irrelevant → anvil impersonates. + // `LmmDemoDispatchDialog` (mounted by `DispatchTransactionDialog`) + // already runs the lido/preview simulator and renders the per-leg + // breakdown. Open it directly. + const isLmmDispatch = + LMM_DEMO_MODE && isLmmDemoDao(matchingRestPolicy.daoAddress); + if (isLmmDispatch) { + const txParams: IDispatchTransactionDialogParams = { + policy: matchingRestPolicy, + network: typedNetwork, + onDispatchSuccess, + }; + open(CapitalFlowDialogId.DISPATCH_TRANSACTION, { + params: txParams, + }); + return; + } + + // `DispatchTransactionDialog` invariants on a connected wallet, + // so route unconnected users through the connect-wallet dialog + // first and only open the dispatch flow once they're connected. + checkWalletConnected({ + onSuccess: () => { + if (supportsTenderly) { + const simulationParams: IDispatchSimulationDialogParams = + { + policy: matchingRestPolicy, + network: typedNetwork, + onDispatchSuccess, + }; + open(CapitalFlowDialogId.DISPATCH_SIMULATION, { + params: simulationParams, + }); + return; + } + + const txParams: IDispatchTransactionDialogParams = { + policy: matchingRestPolicy, + network: typedNetwork, + onDispatchSuccess, + }; + open(CapitalFlowDialogId.DISPATCH_TRANSACTION, { + params: txParams, + }); + }, + }); + }, + [network, open, pushToast, markDispatchPending, checkWalletConnected], + ); + + // Resolve pending dispatches whose tx hash has landed in the Envio snapshot. + useEffect(() => { + if (pendingDispatches.length === 0) { + return; + } + if (data == null) { + return; + } + // Orchestrator (multi-dispatch) policies live in `data.orchestratorPolicies`, + // not `data.policies` — see envioFlowMapper.ts split. Both expose + // `.dispatches`, so a multi-dispatch tx is invisible to this dismiss + // effect unless we walk both lists. Without this the loader spins until + // the 60s hard timeout for every orchestrator dispatch. + const indexedHashes = new Set(); + const allPolicies = [ + ...data.policies, + ...(data.orchestratorPolicies ?? []), + ]; + for (const policy of allPolicies) { + for (const dispatch of policy.dispatches) { + if (dispatch.txHash) { + indexedHashes.add(dispatch.txHash.toLowerCase()); + } + } + } + + const resolved: IFlowPendingDispatch[] = []; + const stillPending: IFlowPendingDispatch[] = []; + for (const pending of pendingDispatches) { + if (indexedHashes.has(pending.txHash.toLowerCase())) { + resolved.push(pending); + } else { + stillPending.push(pending); + } + } + + if (resolved.length === 0) { + return; + } + + setPendingDispatches(stillPending); + for (const pending of resolved) { + pushToast({ + tone: 'success', + title: 'Dispatch confirmed', + description: `${pending.policyName} is now reflected in the feed.`, + }); + } + }, [data, pendingDispatches, pushToast]); + + // Hard timeout — if the indexer is unusually far behind, stop spinning and tell + // the operator to come back later. We run a single interval while any pending + // entry exists; no interval when the queue is empty. + useEffect(() => { + if (pendingDispatches.length === 0) { + return; + } + + const intervalId = setInterval(() => { + const now = Date.now(); + setPendingDispatches((current) => { + const timedOut = current.filter( + (p) => now - p.startedAt > PENDING_DISPATCH_TIMEOUT_MS, + ); + if (timedOut.length === 0) { + return current; + } + for (const pending of timedOut) { + pushToast({ + tone: 'warning', + title: 'Indexer is lagging', + description: `${pending.policyName} dispatch is taking longer than expected — refresh in a moment.`, + }); + } + return current.filter( + (p) => now - p.startedAt <= PENDING_DISPATCH_TIMEOUT_MS, + ); + }); + }, 1000); + + return () => clearInterval(intervalId); + }, [pendingDispatches.length, pushToast]); + + const getPendingDispatch = useCallback( + (policyId: string): IFlowPendingDispatch | undefined => + pendingDispatches.find((p) => p.policyId === policyId), + [pendingDispatches], + ); + + // Only surface the spinner on the *initial* load — once we have a snapshot + // cached we keep showing it and let React-Query refetch silently. This + // avoids the skeleton flashing every time the indexer polls. When the + // feature flag is off the indexer query never runs, so we report + // `isLoading=false` and leave `data=null`; callers render an empty state. + const isLoading = + envioResult.enabled && data == null && !envioResult.isError; + const isError = envioResult.isError && data == null; + + const resolvedDaoId = daoId ?? ''; + // `now` is recomputed inside the memo so it stays stable across renders + // that don't touch `chainNowMs`. Outside demo mode this is always + // `Date.now()` at memo build time — consumers that need a tick-driven + // clock should still call `useState`/`setInterval` directly. + const now = chainNowMs ?? Date.now(); + const value = useMemo( + () => ({ + daoId: resolvedDaoId, + data, + isLoading, + isError, + dispatchPolicy, + pendingDispatches, + getPendingDispatch, + toasts, + dismissToast, + chainNowMs, + now, + liveSnapshot, + }), + [ + resolvedDaoId, + data, + isLoading, + isError, + dispatchPolicy, + pendingDispatches, + getPendingDispatch, + toasts, + dismissToast, + chainNowMs, + now, + liveSnapshot, + ], + ); + + return ( + + {children} + + ); +}; + +export const useFlowDataContext = (): IFlowDataContext => { + const ctx = useContext(FlowDataContext); + if (ctx == null) { + throw new Error( + 'useFlowDataContext: must be used inside .', + ); + } + return ctx; +}; + +/** + * Convenience hook that returns the provider's "effective now" — chain time + * when the LMM fork is warped, wall time otherwise. Components consuming + * relative timestamps should prefer this over calling `Date.now()` directly + * so the LMM demo's warped clock propagates through the UI consistently. + */ +export const useFlowNow = (): number => useFlowDataContext().now; diff --git a/src/modules/flow/providers/flowSelectors.ts b/src/modules/flow/providers/flowSelectors.ts new file mode 100644 index 0000000000..07e0deb9f5 --- /dev/null +++ b/src/modules/flow/providers/flowSelectors.ts @@ -0,0 +1,183 @@ +/** + * Pure selectors that re-derive presentation state from the already-mapped + * `IFlowDaoData` snapshot. They take an explicit `now: number` so callers + * can drive everything off the LMM-demo `chainNowMs` clock without having + * to bake that into the mapper. + * + * Why selectors live here instead of inside `envioFlowMapper.ts`: + * + * - The mapper runs once per Envio refetch and caches its result. Anything + * that depends on "what time is it right now" (cooldown countdown, ready + * gating, queued amounts merged in from a live RPC read) belongs on the + * render path so it reacts to `chainNowMs` ticks and `useLmmLiveSnapshot` + * refreshes without invalidating the React-Query cache. + * - The mapper output type already exposes `cooldown.readyAt` and a + * pre-baked `status`/`statusLabel`. Selectors re-derive both from the + * raw cooldown + the orchestrator's live pending so a single source of + * truth flows through KPI, Activity, Recipients and the detail page. + */ + +import type { LmmLiveSnapshot } from '../demo/useLmmLiveSnapshot'; +import type { + FlowPolicyStatus, + FlowTokenSymbol, + IFlowDaoData, + IFlowPending, + IFlowPolicy, +} from '../types'; + +/** + * Flat list of every policy on the DAO — leaves + orchestrator mirrors — + * so KPI counters, activity feed and recipients table stop ignoring the + * orchestrator surface that lives on `data.orchestratorPolicies`. + * + * Order matches the existing UI conventions: leaf policies first (sorted + * by install date in the mapper), orchestrator mirrors after, so the + * "Recent activity" feed still puts the most-recent leaf executions at + * the top of the timeline by default. + */ +export const getAllPolicies = (data: IFlowDaoData | null): IFlowPolicy[] => { + if (data == null) { + return []; + } + return [...data.policies, ...(data.orchestratorPolicies ?? [])]; +}; + +/** + * Re-derive a policy's `{ status, statusLabel }` against a caller-provided + * `now` (chain-time or wall-time). Mirrors the original mapper logic but + * also opens the door to "first-run ready" — when an orchestrator has + * never dispatched but its budgets are funded (`pending.amount > 0`) we + * surface it as `ready` so the KPI doesn't read "0" while the legs sit + * with 100 stETH queued. + */ +export const derivePolicyStatus = ( + policy: IFlowPolicy, + now: number, + pending?: IFlowPending | null, +): { status: FlowPolicyStatus; statusLabel: string } => { + if (policy.uninstalledAt != null) { + return { status: 'paused', statusLabel: 'Uninstalled' }; + } + if (policy.failedLastDispatch != null) { + return { + status: 'awaiting', + statusLabel: 'Awaiting review · last dispatch failed', + }; + } + if (policy.lastDispatch == null) { + if (pending != null && pending.amount > 0) { + return { status: 'ready', statusLabel: 'Ready to dispatch' }; + } + return { status: 'never', statusLabel: 'Never dispatched' }; + } + if (policy.cooldown != null) { + const readyMs = new Date(policy.cooldown.readyAt).getTime(); + if (readyMs > now) { + return { + status: 'cooldown', + statusLabel: 'Cooldown · awaiting next epoch', + }; + } + return { status: 'ready', statusLabel: 'Ready to dispatch' }; + } + return { status: 'live', statusLabel: 'Live' }; +}; + +/** + * `true` when the policy is dispatch-ready right now. Combines the cooldown + * comparison with the orchestrator-aware "first-run ready" rule so an + * orchestrator with `chain.legs[].budget() > 0` but no recorded dispatch + * still counts toward the KPI. + */ +export const isPolicyReady = ( + policy: IFlowPolicy, + now: number, + pending?: IFlowPending | null, +): boolean => derivePolicyStatus(policy, now, pending).status === 'ready'; + +// --------------------------------------------------------------------------- +// LMM live-snapshot → orchestrator overlay +// --------------------------------------------------------------------------- + +// LMM_DEMO_HACK: in demo mode the orchestrator has 3 legs (Wrap / UniV2 / CowSwap) +// each pulling from its own budget contract. `useLmmLiveSnapshot` reads the +// per-budget amount directly from Anvil; we mirror those readings as a +// `pending` view on the matching `IFlowPolicy` so KPI / status selectors can +// say "100 stETH + 36.4 LDO ready" instead of "0". Production replacement: +// expose an `OrchestratorSnapshot` entity in Envio so the indexer drops +// budget readings into the GraphQL payload directly — see lido-mmd-status.md +// row `pending-from-live`. +export interface ILmmLegAggregate { + label: string; + amount: number; + token: FlowTokenSymbol; +} + +const RAW_TO_NUMBER = (raw: bigint, decimals: number | null): number => { + if (decimals == null || decimals <= 0) { + return Number(raw); + } + const base = BigInt(10) ** BigInt(decimals); + const whole = Number(raw / base); + const frac = Number(raw % base) / Number(base); + return whole + frac; +}; + +/** + * Sum per-token budget readings from the LMM live snapshot and return the + * dominant token aggregate (largest by absolute amount). Used to feed the + * orchestrator's `pending` field so the existing KPI / status selectors + * treat it like any other ready policy. + */ +export const summariseLmmLegs = ( + snapshot: LmmLiveSnapshot | null, +): { dominant?: IFlowPending; legs: ILmmLegAggregate[] } => { + if (snapshot == null) { + return { legs: [] }; + } + const byToken = new Map< + string, + { token: FlowTokenSymbol; amount: number; label: string } + >(); + for (const b of snapshot.budgets) { + const sym = (b.token.symbol ?? + b.token.address.slice(0, 6)) as FlowTokenSymbol; + const amount = RAW_TO_NUMBER(b.amount, b.token.decimals); + const existing = byToken.get(sym); + if (existing) { + existing.amount += amount; + } else { + byToken.set(sym, { token: sym, amount, label: b.label }); + } + } + const legs: ILmmLegAggregate[] = Array.from(byToken.values()) + .map((v) => ({ label: v.label, amount: v.amount, token: v.token })) + .sort((a, b) => b.amount - a.amount); + if (legs.length === 0 || legs[0].amount <= 0) { + return { legs }; + } + return { + dominant: { amount: legs[0].amount, token: legs[0].token }, + legs, + }; +}; + +/** + * Return the `pending` view for a policy. For LMM demo orchestrators this + * is sourced from the live snapshot; everything else falls back to the + * value the mapper already baked in. + */ +export const selectPolicyPending = ( + policy: IFlowPolicy, + lmmOrchestratorPending: IFlowPending | null | undefined, + lmmOrchestratorAddress: string | undefined, +): IFlowPending | null => { + if ( + lmmOrchestratorAddress != null && + policy.address.toLowerCase() === lmmOrchestratorAddress.toLowerCase() + ) { + return lmmOrchestratorPending ?? policy.pending; + } + return policy.pending; +}; diff --git a/src/modules/flow/providers/useEnvioFlowData.ts b/src/modules/flow/providers/useEnvioFlowData.ts new file mode 100644 index 0000000000..bc258091e2 --- /dev/null +++ b/src/modules/flow/providers/useEnvioFlowData.ts @@ -0,0 +1,291 @@ +/** + * Hook that assembles `IFlowDaoData` from the live capital-flow-indexer (Envio) + the + * existing REST `/v2/dao/:id` + `/v2/policies` endpoints + linked accounts. + * + * Returns `{ data, isLoading, isError }` so the Flow provider can skeleton / fall back while + * the query resolves. Only active when the `NEXT_PUBLIC_FLOW_USE_ENVIO` flag is on. + */ + +import { useEffect, useMemo } from 'react'; +import { useProposalList } from '@/modules/governance/api/governanceService'; +import { proposalUtils } from '@/modules/governance/utils/proposalUtils'; +import type { + IDaoPolicy, + ILinkedAccountSummary, + Network, +} from '@/shared/api/daoService'; +import { useDao, useDaoPolicies } from '@/shared/api/daoService'; +import { + flowIndexerKeys, + isFlowIndexerEnabled, + useFlowIndexerDaoData, +} from '@/shared/api/flowIndexer'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import type { IFlowDaoData } from '../types'; +import type { ProposalByTxHash } from '../utils/envioFlowMapper'; +import { buildFlowDataFromEnvio } from '../utils/envioFlowMapper'; +import { buildFlowAddressBook } from '../utils/flowAddressBook'; + +export interface IUseEnvioFlowDataParams { + network: string; + addressOrEns: string; + daoId: string; + /** + * When `true` the underlying indexer query polls every few seconds instead of 30s — + * used by the FlowDataProvider while it has pending dispatches awaiting indexing. + */ + isUrgent?: boolean; +} + +export interface IUseEnvioFlowDataResult { + enabled: boolean; + data: IFlowDaoData | null; + /** + * The REST `/v2/policies` list used by the mapper. Exposed so the provider can + * resolve `IFlowPolicy → IDaoPolicy` (needed to open the on-chain dispatch dialog). + */ + restPolicies: readonly IDaoPolicy[]; + /** + * React-Query key for the indexer snapshot — passed through so callers that want + * to invalidate the cache (e.g. right after a tx broadcast) don't need to know the + * exact parameter shape. + */ + indexerQueryKey: ReturnType; + isLoading: boolean; + isError: boolean; + error?: Error; +} + +const getChainIdForNetwork = (network: string): number | null => { + const def = networkDefinitions[network as Network]; + return def?.id ?? null; +}; + +// Stable reference for `dao.linkedAccounts ?? ` — without this, each +// render creates a new `[]` literal, which busts the downstream `useMemo` +// deps that consume it (daoIds, addressBook, the IFlowDaoData itself) and +// triggers the queryKey churn that produces the setData-loop in the parent +// FlowDataProvider when a DAO has no linked accounts (e.g. the LMM demo DAO). +const EMPTY_LINKED_ACCOUNTS: readonly ILinkedAccountSummary[] = []; + +/** + * Composes the list of `chainId:address` keys the indexer understands for a DAO + its + * linked accounts. Addresses are lower-cased to match how the indexer normalises them. + */ +const composeDaoIds = ( + primary: { network: string; address: string }, + linked: ReadonlyArray<{ network: string; address: string }>, +): string[] => { + const entries = [primary, ...linked] + .map((l) => { + const chainId = getChainIdForNetwork(l.network); + if (!chainId) { + return null; + } + return `${chainId}:${l.address.toLowerCase()}`; + }) + .filter((v): v is string => v !== null); + return Array.from(new Set(entries)); +}; + +export const useEnvioFlowData = ( + params: IUseEnvioFlowDataParams, +): IUseEnvioFlowDataResult => { + const { network, addressOrEns, daoId, isUrgent = false } = params; + const enabled = isFlowIndexerEnabled(); + + const daoQuery = useDao({ urlParams: { id: daoId } }, { enabled }); + + const dao = daoQuery.data; + const daoNetwork = dao?.network ?? (network as Network); + const daoAddress = dao?.address ?? ''; + + const policiesQuery = useDaoPolicies( + { + urlParams: { + network: daoNetwork, + daoAddress, + }, + }, + { enabled: enabled && Boolean(dao?.address) }, + ); + + const chainId = getChainIdForNetwork(network); + const linkedAccounts = dao?.linkedAccounts ?? EMPTY_LINKED_ACCOUNTS; + const daoIds = useMemo( + () => + dao + ? composeDaoIds( + { network: dao.network, address: dao.address }, + linkedAccounts, + ) + : [], + [dao, linkedAccounts], + ); + + const FLOW_INDEXER_EXECUTION_LIMIT = 40; + const indexerQuery = useFlowIndexerDaoData({ + chainId: chainId ?? 0, + daoIds, + executionLimit: FLOW_INDEXER_EXECUTION_LIMIT, + enabled: enabled && daoIds.length > 0, + isUrgent, + }); + const indexerQueryKey = useMemo( + () => + flowIndexerKeys.daoData({ + chainId: chainId ?? 0, + daoIds, + executionLimit: FLOW_INDEXER_EXECUTION_LIMIT, + }), + [chainId, daoIds], + ); + + // Pull the DAO's proposals so the mapper can enrich events/policies with a + // proposal link. The Envio indexer never stores governance proposal data so this + // join lives purely on the client. Backend caps `pageSize` at 50, so we ask for + // that and let the infinite-query machinery page through the rest. This usually + // resolves in 1-3 pages per DAO and is throwaway work once proposal attribution + // lands in the backend. + const PROPOSAL_PAGE_SIZE = 50; + const proposalQuery = useProposalList( + { + queryParams: { + daoId, + pageSize: PROPOSAL_PAGE_SIZE, + includeLinkedAccounts: true, + }, + }, + { enabled: enabled && Boolean(daoId) }, + ); + + // Greedily fetch the remaining pages so every historical proposal has a chance to + // match an install/uninstall/exec tx hash. `fetchNextPage` is a no-op once there's + // no more data, and React-Query dedupes concurrent fetches. + useEffect(() => { + if (!proposalQuery.hasNextPage) { + return; + } + if (proposalQuery.isFetchingNextPage) { + return; + } + void proposalQuery.fetchNextPage(); + }, [ + proposalQuery.hasNextPage, + proposalQuery.isFetchingNextPage, + proposalQuery.fetchNextPage, + ]); + + // Transaction hash → { slug, proposalId }. Lower-cased keys so `lookupProposal` can + // match without the caller having to normalise the hash. We register BOTH + // the proposal-creation tx hash AND the proposal-execution tx hash because + // the indexer can see either depending on the event: + // - An INSTALLED/UNINSTALLED/dispatched event is emitted by the proposal's + // EXECUTION tx (when governance routes through `executeProposal`). + // - The creation tx is the fallback when execution wasn't captured yet + // (e.g. direct admin plugin action that happened to create a proposal + // after the fact). + const proposalByTxHash = useMemo(() => { + const map = new Map< + string, + { slug: string; proposalId: string; title?: string } + >(); + if (!dao || !proposalQuery.data) { + return map; + } + for (const page of proposalQuery.data.pages) { + for (const proposal of page.data) { + const slug = proposalUtils.getProposalSlug(proposal, dao); + if (!slug) { + continue; + } + const title = proposal.title?.trim() || undefined; + const attribution = { slug, proposalId: slug, title }; + if (proposal.transactionHash) { + map.set( + proposal.transactionHash.toLowerCase(), + attribution, + ); + } + const executedHash = proposal.executed?.transactionHash; + if (executedHash) { + map.set(executedHash.toLowerCase(), attribution); + } + } + } + return map; + }, [dao, proposalQuery.data]); + + const isLoading = + enabled && + (daoQuery.isLoading || + policiesQuery.isLoading || + indexerQuery.isLoading); + + const isError = + enabled && + (daoQuery.isError || policiesQuery.isError || indexerQuery.isError); + + const addressBook = useMemo( + () => + buildFlowAddressBook({ + dao: dao + ? { + address: dao.address, + name: dao.name, + ens: dao.ens, + avatar: dao.avatar, + } + : undefined, + linkedAccounts, + restPolicies: policiesQuery.data ?? [], + }), + [dao, linkedAccounts, policiesQuery.data], + ); + + const data = useMemo(() => { + if (!enabled) { + return null; + } + if (!dao || !indexerQuery.data) { + return null; + } + return buildFlowDataFromEnvio({ + network, + addressOrEns, + daoDisplayName: dao.name ?? dao.ens ?? addressOrEns, + indexerData: indexerQuery.data, + restPolicies: policiesQuery.data ?? [], + linkedAccounts, + proposalByTxHash, + addressBook, + }); + }, [ + enabled, + dao, + indexerQuery.data, + policiesQuery.data, + linkedAccounts, + network, + addressOrEns, + proposalByTxHash, + addressBook, + ]); + + const error = + (indexerQuery.error as Error | undefined) ?? + (policiesQuery.error as Error | undefined) ?? + (daoQuery.error as Error | undefined); + + const restPolicies = policiesQuery.data ?? []; + + return { + enabled, + data, + restPolicies, + indexerQueryKey, + isLoading, + isError, + error, + }; +}; diff --git a/src/modules/flow/types.ts b/src/modules/flow/types.ts new file mode 100644 index 0000000000..fdb78f7ec1 --- /dev/null +++ b/src/modules/flow/types.ts @@ -0,0 +1,489 @@ +import type { IFlowIndexedStep } from './canvas/flowGraphTypes'; + +export type FlowPolicyStatus = + | 'ready' + | 'live' + | 'cooldown' + | 'awaiting' + | 'paused' + | 'never'; + +export type FlowPolicyStrategy = + | 'Stream' + | 'Epoch transfer' + | 'Burn' + | 'CoW swap' + | 'Uniswap swap' + | 'Claimer' + | 'Multi-dispatch' + | 'Router'; + +/** + * Canonical symbols we have dedicated styling for. Widened to `string` so the Envio-driven + * data source can surface any ERC-20 symbol without casts; unknown tokens fall back to a + * neutral chip color via `getFlowToken()` in `flowFormatters`. + */ +export type FlowTokenSymbol = 'USDC' | 'MERC' | 'WETH' | (string & {}); + +export interface IFlowToken { + symbol: FlowTokenSymbol; + color: string; + decimals: number; +} + +export interface IFlowRecipient { + address: string; + /** + * Pre-resolved human-readable label. Derived from the client-side address book + * (DAO, linked accounts, REST policies, burn) whenever possible, falling back + * to a truncated address for unknown recipients. UI components should prefer + * `FlowAddressLabel` which additionally enriches `unknown` labels with ENS. + */ + name: string; + /** + * Synchronously-known ENS (e.g. from DAO metadata). Asynchronous ENS resolution + * for unknown addresses happens at render time in `FlowAddressLabel`. + */ + ens?: string | null; + /** + * Role hint from the address book — lets callers tone the chip colour + * (e.g. burn addresses render with a critical accent). + */ + role?: 'dao' | 'linkedaccount' | 'router' | 'subrouter' | 'burn'; + ratio?: string; + pct?: number; + groupLabel?: string; +} + +export interface IFlowDispatch { + id: string; + at: string; + /** + * Primary amount + token for the dispatch. For swap dispatches, this is the OUT leg + * (what downstream recipients received); callers should read `amountIn`/`tokenIn` for + * the IN leg. + */ + amount: number; + token: FlowTokenSymbol; + /** + * Uniswap / CoW swap IN leg — token that went *into* the swap router. + * Populated for `uniswapRouter` strategies (decoded from `exactInputSingle`). + */ + amountIn?: number; + tokenIn?: FlowTokenSymbol; + /** + * Uniswap / CoW swap OUT leg — token that came *out* of the swap. Currently + * only populated when an outbound transfer to a non-swap recipient was + * observed in the same execution (rare on Uniswap, common on CoW). Falls + * back to the REST-declared target token in the UI when unknown. + */ + amountOut?: number; + tokenOut?: FlowTokenSymbol; + recipientsCount: number; + topRecipients: IFlowRecipient[]; + txHash: string; + /** + * When the dispatch tx was executed via a governance proposal we also + * surface the proposal slug so the chart tooltip / activity feed can + * deep-link into the proposal page. + */ + proposalId?: string; + proposalSlug?: string; + /** + * Outcome of the dispatch. When omitted defaults to a successful dispatch. + * `skipped` = the strategy reverted (e.g. gate closed) and the dispatcher + * recorded a `StrategyFailed` event without moving any money. + */ + status?: 'ok' | 'failed' | 'skipped'; + failureReason?: string; + /** Decoded revert reason when `status === 'skipped'`. */ + skippedReason?: string; + /** + * Multi-dispatch / orchestrator context — present when this dispatch is + * a single leg of an orchestrator's `dispatch()` call (e.g. the LMM + * dispatcher firing Wrap → UniV2 LP → CowSwap in sequence). Each leg + * lands as its own IFlowDispatch row; consumers use these fields to + * label rows by leg ("#0 · Wrap leg") and to filter the cumulative + * chart to legs that share the orchestrator's headline token. + */ + legIndex?: number; + legKind?: + | 'WRAP' + | 'UNIV2_LIQUIDITY' + | 'GATED_COWSWAP' + | 'COWSWAP' + | 'TRANSFER' + | 'EPOCH_TRANSFER' + | 'BURN' + | 'UNKNOWN'; +} + +export type FlowEventKind = + | 'policyInstalled' + | 'policyUninstalled' + | 'paused' + | 'resumed' + | 'settingsUpdated' + | 'proposalApplied' + | 'recipientsUpdated' + | 'dispatchFailed'; + +export interface IFlowEvent { + id: string; + kind: FlowEventKind; + at: string; + title: string; + description: string; + proposalId?: string; + proposalSlug?: string; + txHash?: string; +} + +export interface IFlowPolicySchemaAllowance { + type: string; + detail: string; +} + +export interface IFlowPolicySchemaModel { + type: string; + detail: string; +} + +export interface IFlowPolicySchema { + source: string; + allowance: IFlowPolicySchemaAllowance; + model: IFlowPolicySchemaModel; + recipients: IFlowRecipient[]; + subRouters?: IFlowPolicySubRouter[]; +} + +export interface IFlowPolicySubRouter { + id: string; + title: string; + subtitle?: string; + allowance?: IFlowPolicySchemaAllowance; + model?: IFlowPolicySchemaModel; + recipients?: IFlowRecipient[]; + subRouters?: IFlowPolicySubRouter[]; +} + +export interface IFlowLastDispatch { + amount: number; + token: FlowTokenSymbol; + at: string; + txHash: string; + recipientsCount: number; +} + +/** + * Amount currently queued but not yet dispatched by the policy. + */ +export interface IFlowPending { + amount: number; + token: FlowTokenSymbol; +} + +/** + * Cooldown window between dispatches for cadenced policies. + */ +export interface IFlowCooldown { + /** + * Timestamp (ISO) at which the policy becomes dispatchable again. + */ + readyAt: string; + /** + * Total duration of the cooldown window in milliseconds. Used to render + * the progress ring on the policy card. + */ + totalMs: number; +} + +/** + * Snapshot of the latest failed dispatch attempt, surfaced on the card and + * detail page footer. + */ +export interface IFlowFailedLastDispatch { + at: string; + reason: string; + txHash?: string; +} + +export interface IFlowSwapPair { + in: FlowTokenSymbol; + out: FlowTokenSymbol; +} + +export interface IFlowPolicy { + id: string; + /** + * Lowercase plugin address, used for `/settings/automations/{address}` deep links + * and for looking up sub-routers in Multi-dispatch orchestrators. + */ + address: string; + name: string; + description: string; + strategy: FlowPolicyStrategy; + strategyLong: string; + token: FlowTokenSymbol; + /** + * For swap-style routers (`uniswapRouter`, `cowSwapRouter`) the IN/OUT token pair — + * populated from REST metadata and/or the most recent decoded swap leg. + */ + swapPair?: IFlowSwapPair; + status: FlowPolicyStatus; + statusLabel: string; + verb: string; + + createdAt: string; + installedViaProposal: string; + installedViaProposalSlug?: string; + installedViaProposalId?: string; + /** + * Human-readable title of the governance proposal that installed this + * policy, surfaced on the detail page header. Falls back to + * `installedViaProposal` (the slug / `—` placeholder) when the REST + * proposal list doesn't expose a title. + */ + installedViaProposalTitle?: string; + installTxHash: string; + /** + * Timestamp (ISO) of the last `UNINSTALLED` event, present only for archived policies. + * Used to sort the "Archived" pill and to render the uninstall relative chip. + */ + uninstalledAt?: string; + + totalDistributed: number; + forecast30d: number; + nextDispatchLabel: string; + nextDispatchAt?: string; + + pending: IFlowPending | null; + cooldown: IFlowCooldown | null; + failedLastDispatch?: IFlowFailedLastDispatch; + + lastDispatch?: IFlowLastDispatch; + + recipients: IFlowRecipient[]; + recipientsMore: number; + recipientGroup: string; + + dispatches: IFlowDispatch[]; + events: IFlowEvent[]; + schema: IFlowPolicySchema; +} + +/** + * Bucketed view of `IFlowPolicy[]` consumed by `FlowPoliciesSection` to render the pill + * filter. Buckets: + * - `active` — policies that have dispatched at least once and are still installed. + * - `neverRun` — installed policies that have never dispatched (`NEVER_RUN`). + * - `archived` — uninstalled policies, ordered by `uninstalledAt` DESC. + */ +export interface IFlowGroupedPolicies { + active: IFlowPolicy[]; + neverRun: IFlowPolicy[]; + archived: IFlowPolicy[]; +} + +/** + * A single run of a Multi-dispatch orchestrator. Runs are derived client-side by + * grouping the executions of sub-routers by `txHash` — see `envioFlowMapper.ts`. + */ +export interface IFlowOrchestratorRun { + id: string; + at: string; + txHash: string; + legs: IFlowOrchestratorLeg[]; + /** + * Provenance-tagged steps for this run, normalised from the indexer's + * `FlowStep`/`FlowEdge`. When present the workbench canvas projects these + * (real settled amounts) instead of reconstructing legs heuristically. + * One entry per executed/skipped leg — a subset when legs were skipped. + */ + indexedSteps?: IFlowIndexedStep[]; +} + +export interface IFlowOrchestratorLeg { + policyId: string; + policyName: string; + strategy: FlowPolicyStrategy; + amountOut: number; + tokenOut: FlowTokenSymbol; + amountIn?: number; + tokenIn?: FlowTokenSymbol; + recipientsCount: number; + status: 'ok' | 'failed' | 'skipped'; +} + +/** + * Embedded strategy of a DispatcherPlugin — surfaced when the dispatcher + * itself owns the strategy code (Lido demo: Wrap / UniV2 / GatedCowSwap) + * rather than fanning out to separately-installed plugin policies. This is + * additional to `IFlowOrchestrator.chain` (which remains the source of truth + * for legacy multi-routers) and is rendered as a horizontal chip chain on + * the dispatcher card. + */ +export type FlowEmbeddedStrategyKind = + | 'wrap' + | 'univ2Liquidity' + | 'gatedCowSwap' + | 'cowSwap' + | 'transfer' + | 'epochTransfer' + | 'burn' + | 'unknown'; + +export interface IFlowEmbeddedBudget { + kind: 'streamUntil' | 'full' | 'required' | 'unknown'; + /** Floor reserve, raw token base units (string to avoid bigint JSON). */ + floorEpochs?: string; + /** Target epoch for `StreamUntilBudget` strategies. */ + targetEpoch?: string; +} + +export interface IFlowEmbeddedGate { + kind: 'priceFloor' | 'unknown'; + threshold?: string; + /** Max acceptable oracle staleness, seconds. */ + maxStaleness?: string; +} + +export interface IFlowEmbeddedEpoch { + /** Epoch length in seconds. */ + epochLength?: string; +} + +export interface IFlowEmbeddedStrategy { + /** Stable id — strategy contract address. */ + id: string; + address: string; + kind: FlowEmbeddedStrategyKind; + /** Position in the dispatcher's strategy array (display order). */ + index: number; + paused: boolean; + /** Optional human label derived from `kind` (falls back to the kind). */ + label: string; + budget?: IFlowEmbeddedBudget; + gate?: IFlowEmbeddedGate; + epochProvider?: IFlowEmbeddedEpoch; +} + +/** + * A Multi-dispatch / Multi-router / Multi-claimer policy, lifted out of the standard + * policies list and rendered in a dedicated "Orchestrators" section. + */ +export interface IFlowOrchestrator { + id: string; + address: string; + name: string; + description: string; + strategy: FlowPolicyStrategy; + status: FlowPolicyStatus; + statusLabel: string; + + createdAt: string; + installedViaProposalId?: string; + installedViaProposalSlug?: string; + installedViaProposalTitle?: string; + installTxHash: string; + uninstalledAt?: string; + + /** + * Child policies this orchestrator fans out to, in execution order (as declared by + * REST `strategy.subRouters`). Missing children (unknown plugin address) are kept as + * `null` so the diagram can show `[?]` placeholders without collapsing the chain. + */ + chain: Array; + /** + * When the dispatcher owns its strategies inline (Lido Money Machine + * pattern), the embedded chain is surfaced here in execution order. When + * present, the dispatcher card renders this list instead of (or in + * addition to) `chain`. + */ + embeddedStrategies?: IFlowEmbeddedStrategy[]; + runs: IFlowOrchestratorRun[]; + /** + * Latest indexed step per strategy across all runs — the composite the + * default "live" canvas view projects so the full pipeline always renders + * each leg's most recent real amounts, even when individual runs skipped + * legs. Empty until the dispatcher has produced any `FlowStep`. + */ + latestIndexedSteps?: IFlowIndexedStep[]; + lastRunAt?: string; + totalRuns: number; +} + +export interface IFlowDao { + network: string; + addressOrEns: string; + name: string; + avatarColor: string; +} + +export interface IFlowDaoData { + dao: IFlowDao; + /** + * Flat list of leaf (non-orchestrator) policies. Kept for back-compat with callers + * that iterate everything (chart/detail/activity preview). UI that wants the + * pill-filtered view should read `groupedPolicies` instead. + */ + policies: IFlowPolicy[]; + /** + * Leaf policies bucketed for the Policies pill switcher on the Overview page. + */ + groupedPolicies: IFlowGroupedPolicies; + /** + * Multi-dispatch policies lifted into their own section. Each orchestrator owns a + * chain of sub-policies + a list of runs grouped by transaction hash. + */ + orchestrators: IFlowOrchestrator[]; + /** + * Mirror of each orchestrator as an `IFlowPolicy` (same shape leaf policies use). + * Lets the policy detail page resolve `/flow/policies/` without + * polluting the leaf-only `policies` list (KPI / Policies-section consumers + * still iterate `policies` to avoid double-counting orchestrators alongside + * their inline `FlowMultiDispatchCard`). The detail page itself falls back + * to this array when a policy id can't be found in `policies`. + */ + orchestratorPolicies: IFlowPolicy[]; + recipients: IFlowRecipientAggregate[]; +} + +export interface IFlowRecipientAggregate { + address: string; + name: string; + /** + * Resolved ENS for the recipient when synchronously known (e.g. DAO ENS). + */ + ens?: string | null; + /** + * Role hint from the address book — mirrors `IFlowRecipient.role`. + */ + role?: IFlowRecipient['role']; + group: string; + fromPolicyIds: string[]; + amountsByToken: Partial>; + dispatchCount: number; + lastReceivedAt: string; +} + +export const FLOW_TOKENS: Record = { + USDC: { symbol: 'USDC', color: '#2775ca', decimals: 2 }, + MERC: { symbol: 'MERC', color: '#003bf5', decimals: 0 }, + WETH: { symbol: 'WETH', color: '#1f2933', decimals: 3 }, +}; + +/** + * Neutral styling for any ERC-20 we haven't curated colors for (typical of live Envio data). + */ +export const FLOW_TOKEN_FALLBACK_COLOR = '#64748b'; + +/** + * Statuses that indicate the policy is actively operational — used across + * Lede/KPI aggregates. + */ +export const FLOW_ACTIVE_STATUSES: readonly FlowPolicyStatus[] = [ + 'ready', + 'live', + 'cooldown', +]; diff --git a/src/modules/flow/utils/envioFlowMapper.ts b/src/modules/flow/utils/envioFlowMapper.ts new file mode 100644 index 0000000000..a7b26493a9 --- /dev/null +++ b/src/modules/flow/utils/envioFlowMapper.ts @@ -0,0 +1,2018 @@ +/** + * Maps Envio indexer payloads (+ REST `/v2/policies` metadata) into the `IFlowDaoData` shape + * the existing Flow dashboard components consume. + * + * Design notes: + * - On-chain amounts are BigInt raw units. We normalise them to `number` using `token.decimals` + * exactly once (here) so downstream renderers keep using `number` like the mock. + * - Fields the indexer doesn't provide (forecast, pending, full policy schema) are filled with + * best-effort defaults and clearly-marked placeholders so the UI doesn't lie. + * - We prefer REST metadata (name, description, source.epochInterval, swap.targetToken) over + * synthesised values. + * - Proposal attribution is layered on top via `proposalByTxHash` built in the provider from + * `useProposalList`, so the mapper stays free of governance-service coupling. + * - Multi-dispatch policies are lifted into `orchestrators`: each run is reconstructed by + * grouping the child-policy executions that share the same `txHash` (multi-dispatch itself + * never emits `Dispatched`, only its sub-routers do). + */ + +import type { + IDaoPolicy, + ILinkedAccountSummary, +} from '@/shared/api/daoService'; +import { + PolicyStrategySourceType, + PolicyStrategyType, +} from '@/shared/api/daoService'; +import type { + EnvioEmbeddedStrategyKind, + EnvioFlowEdgeRole, + EnvioFlowStepStatus, + EnvioProvenance, + EnvioStrategyType, + IEnvioExecutionTransfer, + IEnvioFlowEdge, + IEnvioFlowStep, + IEnvioPolicy, + IEnvioPolicyEvent, + IEnvioPolicyExecution, + IEnvioRecipientAggregate, + IEnvioStrategy, + IFlowDaoDataResponse, +} from '@/shared/api/flowIndexer'; +import type { + FlowFidelity, + FlowIndexedEdgeRole, + FlowIndexedStatus, + IFlowIndexedEdge, + IFlowIndexedStep, +} from '../canvas/flowGraphTypes'; +import type { + FlowEmbeddedStrategyKind, + FlowEventKind, + FlowPolicyStatus, + FlowPolicyStrategy, + FlowTokenSymbol, + IFlowCooldown, + IFlowDao, + IFlowDaoData, + IFlowDispatch, + IFlowEmbeddedStrategy, + IFlowEvent, + IFlowGroupedPolicies, + IFlowOrchestrator, + IFlowOrchestratorLeg, + IFlowOrchestratorRun, + IFlowPolicy, + IFlowPolicySubRouter, + IFlowRecipient, + IFlowRecipientAggregate, + IFlowSwapPair, +} from '../types'; +import type { IFlowAddressBook } from './flowAddressBook'; +import { EMPTY_FLOW_ADDRESS_BOOK } from './flowAddressBook'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const toMillis = (seconds: string | number | null | undefined): number => { + if (seconds == null) { + return 0; + } + const n = typeof seconds === 'string' ? Number(seconds) : seconds; + return Number.isFinite(n) ? n * 1000 : 0; +}; + +const isoFromSeconds = (seconds: string | number | null | undefined): string => + new Date(toMillis(seconds)).toISOString(); + +const shortenAddress = (addr: string): string => + `${addr.slice(0, 6)}…${addr.slice(-4)}`; + +/** + * Build an `IFlowRecipient` with the address book applied when possible, falling + * back to the shortened address. Keeps the 3 call sites below in lock-step. + */ +const resolveRecipient = ( + addressBook: IFlowAddressBook, + address: string, + extras: Partial> = {}, +): IFlowRecipient => { + const known = addressBook.resolve(address); + if (known) { + return { + address, + name: known.label, + ens: known.ens ?? undefined, + role: known.role, + ...extras, + }; + } + return { + address, + name: shortenAddress(address), + ...extras, + }; +}; + +const BIG_ZERO = BigInt(0); +const BIG_TEN = BigInt(10); +const BIG_100 = BigInt(100); +const BIG_10000 = BigInt(10_000); + +const normaliseAmount = (raw: string, decimals: number): number => { + if (!raw) { + return 0; + } + try { + const big = BigInt(raw); + if (decimals <= 0) { + return Number(big); + } + // Avoid precision loss on huge bigints by splitting into int/frac. + const base = BIG_TEN ** BigInt(decimals); + const whole = Number(big / base); + const frac = Number(big % base) / Number(base); + return whole + frac; + } catch { + return 0; + } +}; + +const STRATEGY_LABEL: Record = { + router: 'Router', + burnRouter: 'Burn', + claimer: 'Claimer', + multiDispatch: 'Multi-dispatch', + multiRouter: 'Multi-dispatch', + multiClaimer: 'Multi-dispatch', + uniswapRouter: 'Uniswap swap', + cowSwapRouter: 'CoW swap', + // LMM_DEMO_HACK: embedded strategies surface as Policy-level types on the + // indexer side too (the orphan-strategy fallback path), so map them to the + // closest end-user-friendly label. Production traffic will not encounter + // these — they always live inside a `multiDispatch` policy. + wrapStrategy: 'Router', + univ2LiquidityStrategy: 'Uniswap swap', + gatedCowSwapStrategy: 'CoW swap', + transferStrategy: 'Router', + epochTransferStrategy: 'Stream', + burnStrategy: 'Burn', +}; + +const STRATEGY_VERB: Record = { + router: 'dispatched', + burnRouter: 'burnt', + claimer: 'claimed', + multiDispatch: 'dispatched', + multiRouter: 'dispatched', + multiClaimer: 'claimed', + uniswapRouter: 'swapped', + cowSwapRouter: 'swapped', + wrapStrategy: 'wrapped', + univ2LiquidityStrategy: 'added as liquidity', + gatedCowSwapStrategy: 'swapped', + transferStrategy: 'transferred', + epochTransferStrategy: 'streamed', + burnStrategy: 'burnt', +}; + +const EVENT_KIND: Record = { + INSTALLED: 'policyInstalled', + INITIALIZED: 'settingsUpdated', + UNINSTALLED: 'policyUninstalled', + SETTINGS_UPDATED: 'settingsUpdated', + FAILSAFE_UPDATED: 'settingsUpdated', + STRATEGY_FAILED: 'dispatchFailed', + STRATEGY_PAUSED: 'paused', + STRATEGY_UNPAUSED: 'resumed', + TARGET_EPOCH_UPDATED: 'settingsUpdated', + FLOOR_EPOCHS_UPDATED: 'settingsUpdated', + EPOCH_LENGTH_UPDATED: 'settingsUpdated', + GATE_THRESHOLD_UPDATED: 'settingsUpdated', + GATE_STALENESS_UPDATED: 'settingsUpdated', + COWSWAP_SETTINGS_UPDATED: 'settingsUpdated', +}; + +const EVENT_TITLE: Record = { + INSTALLED: 'Policy installed', + INITIALIZED: 'Policy initialized', + UNINSTALLED: 'Policy uninstalled', + SETTINGS_UPDATED: 'Settings updated', + FAILSAFE_UPDATED: 'Failsafe map updated', + STRATEGY_FAILED: 'Strategy failed', + STRATEGY_PAUSED: 'Strategy paused', + STRATEGY_UNPAUSED: 'Strategy unpaused', + TARGET_EPOCH_UPDATED: 'Target epoch updated', + FLOOR_EPOCHS_UPDATED: 'Floor epochs updated', + EPOCH_LENGTH_UPDATED: 'Epoch length updated', + GATE_THRESHOLD_UPDATED: 'Gate threshold updated', + GATE_STALENESS_UPDATED: 'Gate staleness updated', + COWSWAP_SETTINGS_UPDATED: 'CoW swap settings updated', +}; + +const isOrchestratorStrategyType = (type: EnvioStrategyType): boolean => + type === 'multiDispatch' || + type === 'multiRouter' || + type === 'multiClaimer'; + +const isSwapStrategyType = (type: EnvioStrategyType): boolean => + type === 'uniswapRouter' || type === 'cowSwapRouter'; + +// Sentinel "burn" destinations used across EVM ecosystems. A router whose +// dispatches go exclusively to one of these is semantically a burn router even +// if the indexer labelled its `strategyType` as plain `router` (e.g. installed +// from a generic repo with a BurnRouterStrategy). +const BURN_DESTINATIONS = new Set([ + '0x0000000000000000000000000000000000000000', + '0x000000000000000000000000000000000000dead', +]); + +const isBurnAddress = (address: string | undefined | null): boolean => + !!address && BURN_DESTINATIONS.has(address.toLowerCase()); + +/** + * Identifies routers whose dispatches effectively burn tokens even though the + * indexer couldn't detect it upfront (plain `router` type with a burn + * strategy). Requires at least one execution where the only outbound recipient + * is a burn sentinel — we never upgrade the label speculatively when the + * policy hasn't run yet. + */ +const detectEffectiveBurn = (envioPolicy: IEnvioPolicy): boolean => { + if (envioPolicy.strategyType !== 'router') { + return false; + } + const outboundRecipients = new Set(); + for (const execution of envioPolicy.executions) { + for (const transfer of execution.transfers) { + if (!isOutboundTransfer(transfer)) { + continue; + } + outboundRecipients.add(transfer.to.toLowerCase()); + } + } + if (outboundRecipients.size === 0) { + return false; + } + for (const recipient of outboundRecipients) { + if (!isBurnAddress(recipient)) { + return false; + } + } + return true; +}; + +// --------------------------------------------------------------------------- +// Proposal attribution +// --------------------------------------------------------------------------- + +export interface IProposalAttribution { + slug: string; + proposalId: string; + /** + * Human-readable proposal title coming from the REST `/proposals` list + * (`IProposal.title`). Used by the policy detail header to render a + * meaningful pill instead of an opaque slug like + * `plugin-0x…-7`. Falls back to the slug when missing. + */ + title?: string; +} + +export type ProposalByTxHash = ReadonlyMap; + +const lookupProposal = ( + map: ProposalByTxHash | undefined, + txHash: string | undefined, +): IProposalAttribution | undefined => { + if (!map || !txHash) { + return undefined; + } + return map.get(txHash.toLowerCase()); +}; + +// --------------------------------------------------------------------------- +// Token / transfer aggregation +// --------------------------------------------------------------------------- + +interface ITokenTotal { + symbol: FlowTokenSymbol; + decimals: number; + amount: number; + rawAmount: bigint; +} + +/** + * Filters out transfers that should never contribute to recipient-facing totals: + * `unknown` opaque calls and `swapIn` internal legs (vault → swap router). + */ +// Internal legs that aren't real "money to recipient" moves. Used to filter +// out approve/wrap/addLiquidity/swap-presign + Uniswap V3 pre-swap from the +// Recipients aggregate and from dispatch-amount math. +const INTERNAL_TRANSFER_KINDS: ReadonlySet = new Set([ + 'unknown', + 'swapIn', + 'wrap', + 'univ2AddLiquidity', + 'swapPresign', + 'approve', +]); + +const isOutboundTransfer = (t: IEnvioExecutionTransfer): boolean => + !INTERNAL_TRANSFER_KINDS.has(t.decodedFrom); + +const sumOutboundByToken = ( + transfers: readonly IEnvioExecutionTransfer[], +): Map => { + const byToken = new Map(); + for (const t of transfers) { + if (!isOutboundTransfer(t)) { + continue; + } + const current = byToken.get(t.token.id) ?? { + symbol: t.token.symbol, + decimals: t.token.decimals, + amount: 0, + rawAmount: BIG_ZERO, + }; + let asBig = BIG_ZERO; + try { + asBig = BigInt(t.amount); + } catch { + asBig = BIG_ZERO; + } + current.rawAmount += asBig; + current.amount += normaliseAmount(t.amount, t.token.decimals); + byToken.set(t.token.id, current); + } + return byToken; +}; + +/** + * Returns the token that accounts for the most raw units across outbound transfers. For the + * dashboard KPI view we pick a single "dominant" token per execution / policy so we can feed + * the existing single-token `IFlowLastDispatch.amount + token` shape without inventing a + * multi-token UI. + */ +const pickDominantOutboundToken = ( + transfers: readonly IEnvioExecutionTransfer[], +): ITokenTotal | null => { + const byToken = sumOutboundByToken(transfers); + let best: ITokenTotal | null = null; + for (const entry of byToken.values()) { + if (!best || entry.rawAmount > best.rawAmount) { + best = entry; + } + } + return best; +}; + +interface IPrimarySwapOrder { + sellSymbol: FlowTokenSymbol; + buySymbol: FlowTokenSymbol; + sellAmountNum: number; + buyAmountNum: number; +} + +/** + * Picks the "primary" CowSwap order for an execution. In practice every + * GatedCowSwap dispatch emits exactly one CowSwapOrderPosted event, so we + * grab the first one we see. Returns null when no orders are attached. + */ +const pickPrimarySwapOrder = ( + swapOrders: IEnvioPolicyExecution['swapOrders'], +): IPrimarySwapOrder | null => { + if (!swapOrders || swapOrders.length === 0) { + return null; + } + const order = swapOrders[0]; + return { + sellSymbol: order.sellToken.symbol as FlowTokenSymbol, + buySymbol: order.buyToken.symbol as FlowTokenSymbol, + sellAmountNum: normaliseAmount( + order.sellAmount, + order.sellToken.decimals, + ), + buyAmountNum: normaliseAmount(order.buyAmount, order.buyToken.decimals), + }; +}; + +const pickSwapInLeg = ( + transfers: readonly IEnvioExecutionTransfer[], +): { symbol: FlowTokenSymbol; amount: number } | null => { + let totalAmount = 0; + let symbol: FlowTokenSymbol | null = null; + let decimals = 0; + for (const t of transfers) { + if (t.decodedFrom !== 'swapIn') { + continue; + } + symbol = t.token.symbol; + decimals = t.token.decimals; + totalAmount += normaliseAmount(t.amount, decimals); + } + if (symbol == null) { + return null; + } + return { symbol, amount: totalAmount }; +}; + +/** + * "Leg-input" headline picker for orchestrator strategies (Lido Money + * Machine and similar). When a leg only emits internal actions — + * `wrap`, `univ2AddLiquidity`, `swapPresign` — there are no outbound + * transfers to surface as the dispatch headline. Falling back to + * "0 USDC" in that case is the existing bug we're fixing; instead, + * pick the dominant (largest-raw-amount) internal transfer and treat + * its token/amount as the headline so History rows read + * "100 stETH · Wrap leg" instead of "0 USDC · 2 recipients". + * + * Skips `approve` (allowance, not consumption) and `swapPresign` + * (CowSwap orders carry sell/buy amounts on `SwapOrder`, picked up by + * the cowOrder branch already). Returns null when no eligible + * internal transfer is found — the caller keeps its existing fallback. + */ +const LEG_INPUT_KINDS: ReadonlySet = new Set([ + 'wrap', + 'univ2AddLiquidity', +]); + +/** + * Sum every leg-input transfer (wrap, univ2AddLiquidity) across all + * executions of an orchestrator policy where the token symbol matches + * `headlineSymbol`. Used to compute the orchestrator's "X stETH ran + * through the machine" KPI without conflating units (we'd otherwise + * be summing stETH + wstETH + LDO together). + */ +const sumLegInputForSymbol = ( + policy: IEnvioPolicy, + headlineSymbol: FlowTokenSymbol, +): number => { + let total = 0; + for (const execution of policy.executions) { + for (const t of execution.transfers) { + if (!LEG_INPUT_KINDS.has(t.decodedFrom)) { + continue; + } + if (t.token.symbol !== headlineSymbol) { + continue; + } + total += normaliseAmount(t.amount, t.token.decimals); + } + } + return total; +}; + +const pickLegInputTransfer = ( + transfers: readonly IEnvioExecutionTransfer[], +): ITokenTotal | null => { + const byToken = new Map(); + for (const t of transfers) { + if (!LEG_INPUT_KINDS.has(t.decodedFrom)) { + continue; + } + const current = byToken.get(t.token.id) ?? { + symbol: t.token.symbol, + decimals: t.token.decimals, + amount: 0, + rawAmount: BIG_ZERO, + }; + let asBig = BIG_ZERO; + try { + asBig = BigInt(t.amount); + } catch { + asBig = BIG_ZERO; + } + current.rawAmount += asBig; + current.amount += normaliseAmount(t.amount, t.token.decimals); + byToken.set(t.token.id, current); + } + let best: ITokenTotal | null = null; + for (const entry of byToken.values()) { + if (!best || entry.rawAmount > best.rawAmount) { + best = entry; + } + } + return best; +}; + +// --------------------------------------------------------------------------- +// Orchestrator (multi-dispatch) currency +// --------------------------------------------------------------------------- + +/** + * Best-effort guess for the orchestrator's "headline currency" — the token + * the user thinks the machine is *moving*, not the dominant outbound token of + * any one leg. Used to label the chart on an orchestrator policy detail page + * so a never-dispatched LMM-style chain doesn't render "0 USDC" by default. + * + * Today this only handles leg-0 = WRAP, where the answer is mechanically + * fixed: a wstETH-output wrap strategy unwraps stETH (the Lido wstETH / + * stETH pair is the only deployed wrap target). Other leg-0 kinds would + * need the budget's underlying ERC-20 symbol, which is not exposed by the + * indexer's `Budget` entity today — those fall through to the caller's + * existing dominant-outbound heuristic. + * + * When the strategy set grows beyond Lido we should either (a) extend the + * indexer schema with `Budget.token`, or (b) parse a richer `configJson` + * with an explicit `inputToken` field on every strategy. + */ +const pickOrchestratorInputToken = ( + envioPolicy: IEnvioPolicy, +): FlowTokenSymbol | undefined => { + const sortedStrategies = (envioPolicy.strategies ?? []) + .filter((s) => s.index != null) + .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); + const leg0 = sortedStrategies[0] ?? envioPolicy.strategies?.[0]; + if (!leg0) { + return undefined; + } + + if (leg0.kind === 'WRAP') { + // configJson shape from capital-flow-indexer/src/multiDispatchBackfill.ts: + // { wstETH: '0x...' } — the OUTPUT. Input is the underlying. + // Lido wstETH is the only on-chain wrap target Capital Router supports + // today; its underlying is stETH. We don't need to inspect the + // address because there's no ambiguity yet. + return 'stETH' as FlowTokenSymbol; + } + + // UNIV2_LIQUIDITY / GATED_COWSWAP / TRANSFER / EPOCH_TRANSFER / BURN / + // UNKNOWN — input lives on the budget contract; not in Hasura yet. + return undefined; +}; + +// --------------------------------------------------------------------------- +// Executions → IFlowDispatch +// --------------------------------------------------------------------------- + +const buildTopRecipients = ( + execution: IEnvioPolicyExecution, + addressBook: IFlowAddressBook, +): IFlowRecipient[] => { + const seen = new Map(); + for (const transfer of execution.transfers) { + if (!isOutboundTransfer(transfer)) { + continue; + } + if (seen.has(transfer.to)) { + continue; + } + seen.set(transfer.to, resolveRecipient(addressBook, transfer.to)); + if (seen.size >= 3) { + break; + } + } + return Array.from(seen.values()); +}; + +const mapExecutionToDispatch = ( + execution: IEnvioPolicyExecution, + addressBook: IFlowAddressBook, + strategyType: EnvioStrategyType, + fallbackOutSymbol: FlowTokenSymbol | undefined, + proposalMap: ProposalByTxHash | undefined, +): IFlowDispatch => { + const dominantOut = pickDominantOutboundToken(execution.transfers); + const swapIn = pickSwapInLeg(execution.transfers); + const uniqueRecipients = new Set( + execution.transfers.filter(isOutboundTransfer).map((t) => t.to), + ); + const proposal = lookupProposal(proposalMap, execution.txHash); + + // CowSwap swap orders posted in the same execution. When present, prefer + // them as the authoritative source of IN/OUT amounts — the dispatcher's + // calldata only sees the IGPv2Settlement.setPreSignature, not the actual + // sell/buy legs. We aggregate over all orders in the execution (in + // practice there's exactly one per dispatch for the demo). + const cowOrder = pickPrimarySwapOrder(execution.swapOrders); + + // Swap strategies: OUT transfers come from the AMM pool (not our calldata) + // so the indexer can't see them. We surface the IN leg as the dispatch's + // primary amount/token — it's the only number we actually know — and keep + // the REST-derived OUT symbol as a display-only chip so the card still + // reads "X WETH → MERC" rather than "0 USDC". + const isSwap = isSwapStrategyType(strategyType); + + // Multi-dispatch leg context — `execution.strategy.kind` carries the + // sub-strategy kind (WRAP / UNIV2_LIQUIDITY / GATED_COWSWAP / …), and + // `execution.strategyIndex` is its position in the orchestrator's + // strategy list. Both null for legacy single-dispatch routers; the + // consumer treats absence as "no leg context, render normally". + const legIndex = + execution.strategyIndex != null && execution.strategyIndex >= 0 + ? execution.strategyIndex + : undefined; + const legKind = execution.strategy?.kind; + + if (cowOrder) { + return { + id: execution.id, + at: isoFromSeconds(execution.blockTimestamp), + amount: cowOrder.sellAmountNum, + token: cowOrder.sellSymbol, + amountIn: cowOrder.sellAmountNum, + tokenIn: cowOrder.sellSymbol, + amountOut: cowOrder.buyAmountNum, + tokenOut: cowOrder.buySymbol ?? fallbackOutSymbol, + recipientsCount: uniqueRecipients.size || execution.transferCount, + topRecipients: buildTopRecipients(execution, addressBook), + txHash: execution.txHash, + proposalId: proposal?.proposalId, + proposalSlug: proposal?.slug, + // Surface skip/skippedReason on the dispatch shape so the chart + // can render a "skipped" marker. IFlowDispatch is extended in + // ../types to carry these optional fields. + status: execution.skipped ? 'skipped' : 'ok', + skippedReason: execution.skippedReason ?? undefined, + legIndex, + legKind, + }; + } + + if (isSwap && swapIn) { + return { + id: execution.id, + at: isoFromSeconds(execution.blockTimestamp), + amount: swapIn.amount, + token: swapIn.symbol, + amountIn: swapIn.amount, + tokenIn: swapIn.symbol, + amountOut: dominantOut?.amount, + tokenOut: + (dominantOut?.symbol as FlowTokenSymbol) ?? fallbackOutSymbol, + recipientsCount: uniqueRecipients.size || execution.transferCount, + topRecipients: buildTopRecipients(execution, addressBook), + txHash: execution.txHash, + proposalId: proposal?.proposalId, + proposalSlug: proposal?.slug, + legIndex, + legKind, + }; + } + + // Orchestrator leg fallback: no outbound transfer, no cowOrder, no + // swap-in — but there may still be a `wrap` / `univ2AddLiquidity` + // transfer carrying the leg's input amount. Without this branch the + // history row falls back to "0 USDC" for every Wrap and UniV2 leg of + // the LMM dispatcher. See `pickLegInputTransfer` for the picker. + // + // NOTE: we deliberately DO NOT populate `tokenIn`/`amountIn` here. + // The chart treats `tokenIn != null && amountIn != null` as the + // "swap dispatch" sigil — surfacing the leg-input under those keys + // would mislabel every wrap/LP dispatch as a Uniswap-style swap on + // the timeline. The leg-input *is* the headline amount for the row, + // so `amount` + `token` carry it on their own. + const legInput = pickLegInputTransfer(execution.transfers); + if (legInput) { + return { + id: execution.id, + at: isoFromSeconds(execution.blockTimestamp), + amount: legInput.amount, + token: legInput.symbol, + // Recipients aren't meaningful for these legs — the produced + // tokens land back on the vault, not on an external address. + // Show 0 so the History description says "No recipients" rather + // than echoing the action count. + recipientsCount: 0, + topRecipients: [], + txHash: execution.txHash, + proposalId: proposal?.proposalId, + proposalSlug: proposal?.slug, + status: execution.skipped ? 'skipped' : 'ok', + skippedReason: execution.skippedReason ?? undefined, + legIndex, + legKind, + }; + } + + // CowSwap-style leg that ran successfully but produced no SwapOrder + // entity yet (either the on-chain `CowSwapOrderPosted` event hasn't + // been ingested, or this is a `setPreSignature(false)` cancellation). + if (legKind === 'GATED_COWSWAP' || legKind === 'COWSWAP') { + // Best-effort sellAmount for the destinations + history rows when + // the `SwapOrder` join is missing. The dispatch's calldata always + // contains an `ERC20.approve(wstETH, relayer, sellAmount)` (the + // dispatcher needs to grant the relayer allowance before the + // pre-sign), and `setPreSignature(orderUid, signed)` itself. The + // indexer decodes the approve into a transfer with + // `decodedFrom = 'approve'` carrying the *actual* sellAmount, + // while `swapPresign` is just a marker (amount = 0n by design — + // see capital-flow-indexer/src/actionDecoder.ts setPreSignature + // branch). Pick the largest non-zero `approve` — `useSafeApproval` + // emits two approves per dispatch, the first to 0, then to + // sellAmount, so picking the max ignores the reset hop. + const approves = execution.transfers.filter( + (t) => t.decodedFrom === 'approve', + ); + let bestApprove: { amount: number; symbol: string } | null = null; + for (const t of approves) { + const v = normaliseAmount(t.amount, t.token.decimals); + if (v > 0 && (bestApprove == null || v > bestApprove.amount)) { + bestApprove = { amount: v, symbol: t.token.symbol }; + } + } + if (bestApprove != null) { + return { + id: execution.id, + at: isoFromSeconds(execution.blockTimestamp), + amount: bestApprove.amount, + token: bestApprove.symbol as FlowTokenSymbol, + amountIn: bestApprove.amount, + tokenIn: bestApprove.symbol as FlowTokenSymbol, + recipientsCount: 0, + topRecipients: [], + txHash: execution.txHash, + proposalId: proposal?.proposalId, + proposalSlug: proposal?.slug, + status: execution.skipped ? 'skipped' : 'ok', + skippedReason: execution.skippedReason ?? undefined, + legIndex, + legKind, + }; + } + return { + id: execution.id, + at: isoFromSeconds(execution.blockTimestamp), + // `0` + empty token string is the agreed sentinel — UI checks + // `dispatch.token === ''` to suppress the amount + chip block + // for "pre-signed without a fill" rows. + amount: 0, + token: '' as FlowTokenSymbol, + recipientsCount: 0, + topRecipients: [], + txHash: execution.txHash, + proposalId: proposal?.proposalId, + proposalSlug: proposal?.slug, + status: execution.skipped ? 'skipped' : 'ok', + skippedReason: execution.skippedReason ?? undefined, + legIndex, + legKind, + }; + } + + const tokenOut = + (dominantOut?.symbol as FlowTokenSymbol) ?? ('USDC' as FlowTokenSymbol); + return { + id: execution.id, + at: isoFromSeconds(execution.blockTimestamp), + amount: dominantOut?.amount ?? 0, + token: tokenOut, + amountIn: swapIn?.amount, + tokenIn: swapIn?.symbol, + recipientsCount: uniqueRecipients.size || execution.transferCount, + topRecipients: buildTopRecipients(execution, addressBook), + txHash: execution.txHash, + proposalId: proposal?.proposalId, + proposalSlug: proposal?.slug, + legIndex, + legKind, + }; +}; + +// --------------------------------------------------------------------------- +// Status derivation +// --------------------------------------------------------------------------- + +const deriveStatus = ( + policy: IEnvioPolicy, + cooldown: IFlowCooldown | null, + recentFailure: boolean, +): { status: FlowPolicyStatus; statusLabel: string } => { + if (policy.status === 'UNINSTALLED') { + return { status: 'paused', statusLabel: 'Uninstalled' }; + } + if (policy.totalDispatches === '0' || !policy.lastDispatchAt) { + return { + status: 'never', + statusLabel: recentFailure + ? 'Awaiting review · last dispatch failed' + : 'Never dispatched', + }; + } + if (recentFailure) { + return { + status: 'awaiting', + statusLabel: 'Awaiting review · last dispatch failed', + }; + } + if (cooldown) { + const readyMs = new Date(cooldown.readyAt).getTime(); + if (readyMs > Date.now()) { + return { + status: 'cooldown', + statusLabel: 'Cooldown · awaiting next epoch', + }; + } + return { status: 'ready', statusLabel: 'Ready to dispatch' }; + } + return { status: 'live', statusLabel: 'Live' }; +}; + +// --------------------------------------------------------------------------- +// Cooldown (streams) — uses REST.epochInterval + Envio.lastDispatchAt +// --------------------------------------------------------------------------- + +const deriveCooldown = ( + policy: IEnvioPolicy, + restPolicy: IDaoPolicy | undefined, +): IFlowCooldown | null => { + if (!policy.lastDispatchAt) { + return null; + } + + // 1. Preferred: derive from StreamUntilBudget on one of the embedded + // strategies. The dispatcher's "next firing time" is bounded by the + // fastest stream — pick the smallest epochLength × floorEpochs. + const streamCooldown = deriveStreamCooldownFromStrategies(policy); + if (streamCooldown) { + return streamCooldown; + } + + // 2. Fallback: legacy REST-driven cooldown for single-dispatch routers + // whose policy.strategy.source carries the streamBalance metadata. + if ( + restPolicy?.strategy.source?.type !== + PolicyStrategySourceType.STREAM_BALANCE + ) { + return null; + } + const epochSeconds = restPolicy.strategy.source.epochInterval; + if (!epochSeconds) { + return null; + } + const readyAtMs = toMillis(policy.lastDispatchAt) + epochSeconds * 1000; + return { + readyAt: new Date(readyAtMs).toISOString(), + totalMs: epochSeconds * 1000, + }; +}; + +const deriveStreamCooldownFromStrategies = ( + policy: IEnvioPolicy, +): IFlowCooldown | null => { + const strategies = policy.strategies; + if (!strategies || strategies.length === 0 || !policy.lastDispatchAt) { + return null; + } + let bestSeconds: number | undefined; + for (const s of strategies) { + const budget = s.budget; + const epochProvider = s.epochProvider; + if (budget?.kind !== 'STREAM_UNTIL') { + continue; + } + if (!epochProvider?.epochLength) { + continue; + } + const epochLength = Number(epochProvider.epochLength); + const floorEpochs = budget.floorEpochs ? Number(budget.floorEpochs) : 1; + if (!Number.isFinite(epochLength) || epochLength <= 0) { + continue; + } + const cooldownSeconds = epochLength * Math.max(1, floorEpochs); + if (bestSeconds == null || cooldownSeconds < bestSeconds) { + bestSeconds = cooldownSeconds; + } + } + if (bestSeconds == null) { + return null; + } + const readyAtMs = toMillis(policy.lastDispatchAt) + bestSeconds * 1000; + return { + readyAt: new Date(readyAtMs).toISOString(), + totalMs: bestSeconds * 1000, + }; +}; + +// --------------------------------------------------------------------------- +// Swap pair (REST metadata → chip) +// --------------------------------------------------------------------------- + +const deriveSwapPair = ( + envioPolicy: IEnvioPolicy, + restPolicy: IDaoPolicy | undefined, +): IFlowSwapPair | undefined => { + if (!isSwapStrategyType(envioPolicy.strategyType)) { + return undefined; + } + + // Prefer REST metadata — it always reports the configured source/target, even for + // never-run policies that have no execution data yet. + const restIn = restPolicy?.strategy.source?.token?.symbol; + const restOut = restPolicy?.strategy.swap?.targetToken?.symbol; + if (restIn && restOut) { + return { in: restIn, out: restOut }; + } + + // Fall back to the latest execution — handy when REST metadata is missing or stale. + const latest = + envioPolicy.lastExecution ?? envioPolicy.executions[0] ?? null; + if (!latest) { + return undefined; + } + const out = pickDominantOutboundToken(latest.transfers)?.symbol; + const inLeg = pickSwapInLeg(latest.transfers)?.symbol; + if (!out || !inLeg) { + return undefined; + } + return { in: inLeg, out }; +}; + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +const mapEvent = ( + event: IEnvioPolicyEvent, + proposalMap: ProposalByTxHash | undefined, +): IFlowEvent => { + const attribution = lookupProposal(proposalMap, event.txHash); + return { + id: event.id, + kind: EVENT_KIND[event.kind] ?? 'settingsUpdated', + at: isoFromSeconds(event.blockTimestamp), + title: EVENT_TITLE[event.kind] ?? 'Policy event', + description: event.description, + txHash: event.txHash, + proposalId: attribution?.proposalId, + proposalSlug: attribution?.slug, + }; +}; + +// --------------------------------------------------------------------------- +// Policy +// --------------------------------------------------------------------------- + +const strategyLong = ( + type: EnvioStrategyType, + restPolicy: IDaoPolicy | undefined, +): string => { + const base = STRATEGY_LABEL[type]; + const source = restPolicy?.strategy.source; + if ( + source?.type === PolicyStrategySourceType.STREAM_BALANCE && + source.epochInterval + ) { + const days = Math.round(source.epochInterval / 86_400); + return `${base} · ${days}d epoch`; + } + return base; +}; + +const buildPolicyRecipients = ( + recipientAggregates: readonly IEnvioRecipientAggregate[], + policyId: string, + addressBook: IFlowAddressBook, +): IFlowRecipient[] => { + const forPolicy = recipientAggregates.filter( + (r) => r.policy.id === policyId, + ); + if (forPolicy.length === 0) { + return []; + } + + // Pick the dominant token for this policy so pct labels make sense, then compute shares. + const totalsByToken = new Map(); + for (const r of forPolicy) { + try { + totalsByToken.set( + r.token.id, + (totalsByToken.get(r.token.id) ?? BIG_ZERO) + + BigInt(r.totalAmount), + ); + } catch { + /* skip unparseable */ + } + } + let dominantTokenId: string | null = null; + let dominantTotal = BIG_ZERO; + for (const [id, total] of totalsByToken.entries()) { + if (total > dominantTotal) { + dominantTotal = total; + dominantTokenId = id; + } + } + if (!dominantTokenId || dominantTotal === BIG_ZERO) { + return []; + } + + const forDominant = forPolicy + .filter((r) => r.token.id === dominantTokenId) + .sort((a, b) => { + try { + return Number(BigInt(b.totalAmount) - BigInt(a.totalAmount)); + } catch { + return 0; + } + }) + .slice(0, 6); + + return forDominant.map((r) => { + let pct: number | undefined; + try { + pct = + Number((BigInt(r.totalAmount) * BIG_10000) / dominantTotal) / + Number(BIG_100); + } catch { + pct = undefined; + } + return resolveRecipient(addressBook, r.recipient, { pct }); + }); +}; + +const mapPolicy = (params: { + envioPolicy: IEnvioPolicy; + restPolicy: IDaoPolicy | undefined; + recipientAggregates: readonly IEnvioRecipientAggregate[]; + proposalByTxHash: ProposalByTxHash | undefined; + addressBook: IFlowAddressBook; +}): IFlowPolicy => { + const { + envioPolicy, + restPolicy, + recipientAggregates, + proposalByTxHash, + addressBook, + } = params; + const strategyType = envioPolicy.strategyType; + const isSwap = isSwapStrategyType(strategyType); + + const recipients = buildPolicyRecipients( + recipientAggregates, + envioPolicy.id, + addressBook, + ); + + // Effective-burn heuristic: the indexer reports `strategyType: 'router'` for + // routers installed from generic repos even when every dispatch feeds a + // burn destination. Treat such routers as "Burn" so overview chips stop + // saying "Router" for what is semantically a burn. + const effectiveStrategyType = detectEffectiveBurn(envioPolicy) + ? ('burnRouter' as EnvioStrategyType) + : strategyType; + + const swapPair = deriveSwapPair(envioPolicy, restPolicy); + const restOutSymbol = restPolicy?.strategy.swap?.targetToken?.symbol as + | FlowTokenSymbol + | undefined; + + // Aggregate totals across ALL executions for "total distributed", ignoring unknown + // transfers. Swap-noise (`swapIn`) is also excluded so Total = OUT-side only, + // EXCEPT for swap strategies where OUT transfers aren't indexable (Uniswap + // pools settle tokens via internal accounting, not via the calldata we + // decode). For swaps we fall back to the IN leg sum so the card stops + // reading "0 USDC" when there clearly have been swaps. + const allTransfers = envioPolicy.executions.flatMap((e) => e.transfers); + const dominantAll = pickDominantOutboundToken(allTransfers); + const swapInAll = pickSwapInLeg(allTransfers); + const lastExecution = + envioPolicy.lastExecution ?? envioPolicy.executions[0] ?? null; + const dominantLast = lastExecution + ? pickDominantOutboundToken(lastExecution.transfers) + : null; + const swapInLast = lastExecution + ? pickSwapInLeg(lastExecution.transfers) + : null; + + // For orchestrator (multi-dispatch) policies, prefer the headline input + // token of leg 0 over the dominant-outbound heuristic. The latter picks + // the largest outbound transfer across all legs (e.g. wstETH after a + // WRAP), which is misleading on a "Lido Money Machine"-style chain where + // the user thinks of the machine as a stETH pipeline. Empty for non- + // orchestrators and for orchestrators we can't classify yet — both fall + // through to the existing dominant logic + USDC tail fallback. + const orchestratorToken = isOrchestratorStrategyType(strategyType) + ? pickOrchestratorInputToken(envioPolicy) + : undefined; + + const policyToken = + orchestratorToken ?? + (isSwap && dominantAll == null + ? ((swapInAll?.symbol as FlowTokenSymbol) ?? + (swapPair?.in as FlowTokenSymbol) ?? + ('USDC' as FlowTokenSymbol)) + : ((dominantAll?.symbol as FlowTokenSymbol) ?? + (dominantLast?.symbol as FlowTokenSymbol) ?? + ('USDC' as FlowTokenSymbol))); + + // Orchestrator (multi-dispatch) total: there are no outbound transfers + // we can dominant-sum across (every leg's tokens stay internal to the + // pipeline), so fall back to summing the "leg-input" transfers for the + // headline token only — e.g. for LMM that's the stETH consumed by the + // Wrap leg across every dispatch. Mixing wstETH/LDO amounts into the + // same headline would conflate units, so we keep it strictly to the + // token chosen by `pickOrchestratorInputToken`. + const isOrchestrator = isOrchestratorStrategyType(strategyType); + const orchestratorHeadlineTotal = isOrchestrator + ? sumLegInputForSymbol(envioPolicy, policyToken) + : 0; + + const policyTotalDistributed = isOrchestrator + ? orchestratorHeadlineTotal + : isSwap && dominantAll == null + ? (swapInAll?.amount ?? 0) + : (dominantAll?.amount ?? 0); + + const cooldown = deriveCooldown(envioPolicy, restPolicy); + const dispatches = envioPolicy.executions.map((execution) => + mapExecutionToDispatch( + execution, + addressBook, + strategyType, + restOutSymbol, + proposalByTxHash, + ), + ); + // LMM_DEMO_HACK: synthetic-policy-installed. The indexer's `INSTALLED` + // event is emitted by the `PolicyInstalled` topic, but the LMM dispatcher + // plugin replays a custom `Initialized` topic on first deploy and the + // current `capital-flow-indexer` registers only one of the two — so the + // chart loses its leftmost lifecycle marker whenever any other event + // (e.g. `STRATEGY_PAUSED`) lands first. We always inject a synthetic + // `policyInstalled` row here so the chart and history always carry the + // install marker, regardless of which other events accompanied it. + // Production replacement: emit a canonical `INSTALLED` row from the + // indexer for every plugin install — see lido-mmd-status.md + // `synthetic-policy-installed`. + const mappedEvents = envioPolicy.events.map((e) => + mapEvent(e, proposalByTxHash), + ); + const hasInstalledEvent = mappedEvents.some( + (e) => e.kind === 'policyInstalled', + ); + const events: IFlowEvent[] = hasInstalledEvent + ? mappedEvents + : [ + ...mappedEvents, + { + id: `${envioPolicy.id}-installed`, + kind: 'policyInstalled' as FlowEventKind, + at: isoFromSeconds(envioPolicy.installedAt), + title: 'Policy installed', + description: `Installed at block ${envioPolicy.installBlockNumber}.`, + txHash: envioPolicy.installTxHash, + proposalId: lookupProposal( + proposalByTxHash, + envioPolicy.installTxHash, + )?.proposalId, + proposalSlug: lookupProposal( + proposalByTxHash, + envioPolicy.installTxHash, + )?.slug, + }, + ]; + // The current on-chain plugin ABI doesn't emit dedicated failure events, so we have no + // real-time signal for a failed dispatch. Keep the hook for future failure events. + const recentFailure = false; + const { status, statusLabel } = deriveStatus( + envioPolicy, + cooldown, + recentFailure, + ); + + const nextDispatchAt = cooldown?.readyAt; + const nextDispatchLabel = cooldown + ? `Streams · next at ${new Date(cooldown.readyAt).toLocaleDateString()}` + : status === 'never' + ? 'Not yet dispatched' + : 'Pull-based'; + + const installTxHash = envioPolicy.installTxHash; + const installAttribution = lookupProposal(proposalByTxHash, installTxHash); + const uninstallEvent = envioPolicy.events.find( + (e) => e.kind === 'UNINSTALLED', + ); + const uninstalledAt = uninstallEvent + ? isoFromSeconds(uninstallEvent.blockTimestamp) + : undefined; + + const address = envioPolicy.pluginAddress.toLowerCase(); + + // For swap strategies the OUT leg is opaque, so "last dispatch" should quote + // the IN leg rather than a spurious 0 in the declared target token. + const lastDispatchAmount = + isSwap && dominantLast == null + ? (swapInLast?.amount ?? 0) + : (dominantLast?.amount ?? 0); + const lastDispatchToken = + isSwap && dominantLast == null + ? ((swapInLast?.symbol as FlowTokenSymbol) ?? policyToken) + : ((dominantLast?.symbol as FlowTokenSymbol) ?? policyToken); + + return { + id: envioPolicy.id, + address, + name: + restPolicy?.name ?? + `Policy ${shortenAddress(envioPolicy.pluginAddress)}`, + description: + restPolicy?.description ?? + `${STRATEGY_LABEL[effectiveStrategyType]} plugin installed on ${shortenAddress(envioPolicy.dao.address)}.`, + strategy: STRATEGY_LABEL[effectiveStrategyType], + strategyLong: strategyLong(effectiveStrategyType, restPolicy), + token: policyToken, + swapPair, + status, + statusLabel, + verb: STRATEGY_VERB[effectiveStrategyType], + createdAt: isoFromSeconds(envioPolicy.installedAt), + installedViaProposal: installAttribution?.proposalId ?? '—', + installedViaProposalId: installAttribution?.proposalId, + installedViaProposalSlug: installAttribution?.slug, + installedViaProposalTitle: installAttribution?.title, + installTxHash, + uninstalledAt, + totalDistributed: policyTotalDistributed, + forecast30d: 0, + nextDispatchLabel, + nextDispatchAt, + pending: null, + cooldown, + lastDispatch: lastExecution + ? { + amount: lastDispatchAmount, + token: lastDispatchToken, + at: isoFromSeconds(lastExecution.blockTimestamp), + txHash: lastExecution.txHash, + recipientsCount: new Set( + lastExecution.transfers + .filter(isOutboundTransfer) + .map((t) => t.to), + ).size, + } + : undefined, + recipients, + recipientsMore: Math.max( + 0, + recipientAggregates.filter((r) => r.policy.id === envioPolicy.id) + .length - recipients.length, + ), + recipientGroup: + recipients.length > 0 + ? `Recipients (${recipients.length})` + : 'No recipients yet', + dispatches, + events, + schema: { + source: restPolicy?.strategy.source?.type ?? '—', + allowance: { + type: restPolicy?.strategy.source?.type ?? '—', + detail: + restPolicy?.strategy.source?.type === + PolicyStrategySourceType.STREAM_BALANCE + ? `Stream · epoch ${restPolicy.strategy.source.epochInterval}s` + : '—', + }, + model: { + type: restPolicy?.strategy.model?.type ?? '—', + detail: '—', + }, + recipients: recipients.map((r, i) => ({ + ...r, + ratio: + typeof r.pct === 'number' + ? `${r.pct.toFixed(1)}%` + : `slot ${i + 1}`, + })), + // Multi-dispatch orchestrators are rendered through `FlowPolicyStructure` + // → `FlowPolicyTree`, which short-circuits to `LmmPolicyTopology` for the + // demo dispatcher (driven by `useLmmManifest`, not by this array). But + // `FlowPolicyStructure` still gates rendering on `subRouters != null`, + // so we have to surface a non-null array for orchestrators — empty is + // fine since the topology view derives its graph from `inspect()`. + // For pre-LMM orchestrators (multiRouter / multiClaimer) we still + // honour whatever the REST policy declared. + subRouters: isOrchestratorStrategyType(strategyType) + ? buildSubRouterStubs(envioPolicy, restPolicy) + : undefined, + }, + }; +}; + +/** + * Lightweight `IFlowPolicySubRouter[]` builder for orchestrator policies. + * Prefers REST `strategy.subRouters` (the legacy multi-router shape — addresses + * point at sibling Policy entities) and falls back to envio `Policy.strategies` + * (the LMM embedded shape — addresses point at sub-contracts that are NOT + * separately installed via PSP). Either way every entry has at least an `id` + * + a `title`, which is enough for the SVG tree fallback; the rich topology + * view (`LmmPolicyTopology`) builds its own nodes from on-chain `inspect()`. + */ +const buildSubRouterStubs = ( + envioPolicy: IEnvioPolicy, + restPolicy: IDaoPolicy | undefined, +): IFlowPolicySubRouter[] => { + const restSubRouters = restPolicy?.strategy.subRouters ?? []; + if (restSubRouters.length > 0) { + return restSubRouters.map((address) => ({ + id: address.toLowerCase(), + title: shortenAddress(address), + subtitle: 'Sub-router', + })); + } + const embedded = envioPolicy.strategies ?? []; + return embedded + .slice() + .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) + .map((s) => { + const kind = EMBEDDED_STRATEGY_KIND_MAP[s.kind] ?? 'unknown'; + return { + id: s.id, + title: EMBEDDED_STRATEGY_LABEL[kind], + subtitle: shortenAddress(s.address), + }; + }); +}; + +// --------------------------------------------------------------------------- +// Embedded strategy chain (LMM Money Machine pattern) +// --------------------------------------------------------------------------- + +const EMBEDDED_STRATEGY_KIND_MAP: Record< + EnvioEmbeddedStrategyKind, + FlowEmbeddedStrategyKind +> = { + WRAP: 'wrap', + UNIV2_LIQUIDITY: 'univ2Liquidity', + GATED_COWSWAP: 'gatedCowSwap', + COWSWAP: 'cowSwap', + TRANSFER: 'transfer', + EPOCH_TRANSFER: 'epochTransfer', + BURN: 'burn', + UNKNOWN: 'unknown', +}; + +const EMBEDDED_STRATEGY_LABEL: Record = { + wrap: 'Wrap', + univ2Liquidity: 'UniV2 Liquidity', + gatedCowSwap: 'Gated CoW swap', + cowSwap: 'CoW swap', + transfer: 'Transfer', + epochTransfer: 'Epoch transfer', + burn: 'Burn', + unknown: 'Strategy', +}; + +const mapEmbeddedStrategies = ( + strategies: IEnvioStrategy[] | undefined, +): IFlowEmbeddedStrategy[] | undefined => { + if (!strategies || strategies.length === 0) { + return undefined; + } + const mapped = strategies + .slice() + .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) + .map((s, i): IFlowEmbeddedStrategy => { + const kind = EMBEDDED_STRATEGY_KIND_MAP[s.kind] ?? 'unknown'; + return { + id: s.id, + address: s.address.toLowerCase(), + kind, + index: s.index ?? i, + paused: s.paused, + label: EMBEDDED_STRATEGY_LABEL[kind], + budget: s.budget + ? { + kind: + s.budget.kind === 'STREAM_UNTIL' + ? 'streamUntil' + : s.budget.kind === 'FULL' + ? 'full' + : s.budget.kind === 'REQUIRED' + ? 'required' + : 'unknown', + floorEpochs: s.budget.floorEpochs ?? undefined, + targetEpoch: s.budget.targetEpoch ?? undefined, + } + : undefined, + gate: s.gate + ? { + kind: + s.gate.kind === 'PRICE_FLOOR' + ? 'priceFloor' + : 'unknown', + threshold: s.gate.threshold ?? undefined, + maxStaleness: s.gate.maxStaleness ?? undefined, + } + : undefined, + epochProvider: s.epochProvider + ? { + epochLength: s.epochProvider.epochLength ?? undefined, + } + : undefined, + }; + }); + return mapped; +}; + +// --------------------------------------------------------------------------- +// Indexed provenance graph (FlowStep / FlowEdge) → IFlowIndexedStep +// --------------------------------------------------------------------------- + +/** + * Collapse the indexer's six-level provenance onto the three-level fidelity the + * canvas styles by (solid `real` / dashed `~estimated` / ghost `opaque`). + * Mirrors `flowDynamics.fidelityOf` for the uppercase Envio enum. + */ +const fidelityOfProvenance = (p: EnvioProvenance): FlowFidelity => { + if (p === 'OPAQUE') { + return 'opaque'; + } + if (p === 'ESTIMATED_VIA_QUOTER' || p === 'ESTIMATED_VIA_ORACLE') { + return 'estimated'; + } + return 'real'; +}; + +const FLOW_EDGE_ROLE_MAP: Record = { + VAULT_OUT: 'vaultOut', + VAULT_IN: 'vaultIn', + EXTERNAL: 'external', +}; + +const FLOW_STEP_STATUS_MAP: Record = { + EXECUTED: 'executed', + NO_OP: 'noOp', + SKIPPED_PAUSED: 'skippedPaused', + SKIPPED_GATED: 'skippedGated', + FAILED: 'failed', + OPAQUE: 'opaque', +}; + +const mapFlowEdge = (edge: IEnvioFlowEdge): IFlowIndexedEdge => { + const fidelity = fidelityOfProvenance(edge.provenance); + return { + role: FLOW_EDGE_ROLE_MAP[edge.role], + token: edge.token.symbol as FlowTokenSymbol, + to: edge.to, + // Opaque amounts are unknown until settled — never fabricate a number. + amount: + fidelity === 'opaque' + ? null + : normaliseAmount(edge.amount, edge.token.decimals), + fidelity, + pending: edge.pending, + }; +}; + +const mapFlowStep = (step: IEnvioFlowStep): IFlowIndexedStep => ({ + address: (step.strategy?.address ?? '').toLowerCase(), + index: step.index, + kind: EMBEDDED_STRATEGY_KIND_MAP[step.kind] ?? 'unknown', + status: FLOW_STEP_STATUS_MAP[step.status] ?? 'opaque', + reason: step.reason ?? undefined, + pending: step.pending, + edges: step.edges.map(mapFlowEdge), +}); + +/** + * Group every indexer `FlowStep` by dispatcher plugin address, preserving the + * incoming newest-first order. Used by the orchestrator builder to attach + * per-run steps + the latest-per-strategy composite without a second query. + */ +const groupFlowStepsByDispatcher = ( + flowSteps: readonly IEnvioFlowStep[], +): Map => { + const byDispatcher = new Map(); + for (const step of flowSteps) { + const key = step.dispatcher.pluginAddress.toLowerCase(); + const list = byDispatcher.get(key); + if (list) { + list.push(step); + } else { + byDispatcher.set(key, [step]); + } + } + return byDispatcher; +}; + +/** Strategy-kind → end-user strategy label, so FlowStep-derived legs feed the + * workbench's buyback/swap classification the same way child-policy legs did. */ +const KIND_TO_STRATEGY_LABEL: Record< + FlowEmbeddedStrategyKind, + FlowPolicyStrategy +> = { + wrap: 'Router', + univ2Liquidity: 'Uniswap swap', + gatedCowSwap: 'CoW swap', + cowSwap: 'CoW swap', + transfer: 'Router', + epochTransfer: 'Stream', + burn: 'Burn', + unknown: 'Router', +}; + +const INDEXED_STATUS_TO_LEG: Record< + FlowIndexedStatus, + IFlowOrchestratorLeg['status'] +> = { + executed: 'ok', + opaque: 'ok', + noOp: 'skipped', + skippedGated: 'skipped', + skippedPaused: 'skipped', + failed: 'failed', +}; + +/** + * Derive a presentational `IFlowOrchestratorLeg` from an indexed step. Amounts + * come straight from the provenance-tagged edges (real settled values) — no + * approve/wrap heuristics. `out` is the vault-in (or external) edge; `in` is + * the first vault-out edge. + */ +const legFromIndexedStep = (step: IFlowIndexedStep): IFlowOrchestratorLeg => { + const out = + step.edges.find((e) => e.role === 'vaultIn') ?? + step.edges.find((e) => e.role === 'external'); + const input = step.edges.find((e) => e.role === 'vaultOut'); + const recipients = new Set( + step.edges.filter((e) => e.role === 'external').map((e) => e.to), + ); + return { + policyId: step.address || `idx:${step.index}`, + policyName: EMBEDDED_STRATEGY_LABEL[step.kind], + strategy: KIND_TO_STRATEGY_LABEL[step.kind], + amountOut: out?.amount ?? 0, + tokenOut: (out?.token ?? input?.token ?? '?') as FlowTokenSymbol, + amountIn: input?.amount ?? undefined, + tokenIn: input?.token as FlowTokenSymbol | undefined, + recipientsCount: recipients.size, + status: INDEXED_STATUS_TO_LEG[step.status], + }; +}; + +/** + * Reconstruct an orchestrator's runs directly from its provenance-tagged + * `FlowStep`s — the dispatcher emits one per executed/skipped leg, all sharing + * the dispatch tx hash. This is the authoritative path (real amounts, correct + * even when the dispatcher owns its strategies inline with no child policies) + * and supersedes the child-dispatch heuristic reconstruction. + * + * `flowSteps` arrive newest-first; returns runs newest-first, each carrying its + * `indexedSteps` (index-sorted) for the canvas, plus the latest-per-strategy + * composite for the default live view. + */ +const buildRunsFromFlowSteps = ( + flowSteps: readonly IEnvioFlowStep[], +): { runs: IFlowOrchestratorRun[]; latestIndexedSteps: IFlowIndexedStep[] } => { + const byTx = new Map< + string, + { at: string; ts: number; steps: IEnvioFlowStep[] } + >(); + for (const step of flowSteps) { + const entry = byTx.get(step.txHash); + const ts = Number(step.blockTimestamp); + if (entry) { + entry.steps.push(step); + if (ts > entry.ts) { + entry.ts = ts; + entry.at = isoFromSeconds(step.blockTimestamp); + } + } else { + byTx.set(step.txHash, { + at: isoFromSeconds(step.blockTimestamp), + ts, + steps: [step], + }); + } + } + + const runs: IFlowOrchestratorRun[] = Array.from(byTx.entries()) + .map(([txHash, entry]) => { + const indexedSteps = entry.steps + .map(mapFlowStep) + .sort((a, b) => a.index - b.index); + return { + id: `run-${txHash}`, + at: entry.at, + ts: entry.ts, + txHash, + legs: indexedSteps.map(legFromIndexedStep), + indexedSteps, + }; + }) + .sort((a, b) => b.ts - a.ts) + .map(({ ts: _ts, ...run }) => run); + + // Latest step per strategy (steps are newest-first) → full-pipeline overview. + const latestByStrategy = new Map(); + for (const step of flowSteps) { + const key = + step.strategy?.address?.toLowerCase() ?? `idx:${step.index}`; + if (!latestByStrategy.has(key)) { + latestByStrategy.set(key, step); + } + } + const latestIndexedSteps = Array.from(latestByStrategy.values()) + .map(mapFlowStep) + .sort((a, b) => a.index - b.index); + + return { runs, latestIndexedSteps }; +}; + +// --------------------------------------------------------------------------- +// Orchestrator synthesis (Multi-dispatch) +// --------------------------------------------------------------------------- + +const buildOrchestrator = (params: { + envioPolicy: IEnvioPolicy; + restPolicy: IDaoPolicy | undefined; + policiesByAddress: Map; + proposalByTxHash: ProposalByTxHash | undefined; + /** Provenance-tagged steps for THIS dispatcher, newest-first. */ + flowSteps: readonly IEnvioFlowStep[]; +}): IFlowOrchestrator => { + const { + envioPolicy, + restPolicy, + policiesByAddress, + proposalByTxHash, + flowSteps, + } = params; + + const subRouterAddresses = (restPolicy?.strategy.subRouters ?? []).map( + (a) => a.toLowerCase(), + ); + const chain: Array = subRouterAddresses.map( + (addr) => policiesByAddress.get(addr) ?? null, + ); + + // Preferred path: reconstruct runs from the dispatcher's own provenance + // FlowSteps (real amounts; correct for inline-strategy dispatchers with no + // child policies). Falls back to child-dispatch grouping for legacy + // multi-routers that fan out to separate plugins and emit no FlowSteps. + const indexed = + flowSteps.length > 0 ? buildRunsFromFlowSteps(flowSteps) : null; + + // Group child-policy dispatches by transaction hash. Since `multiDispatch` triggers all + // its children inside a single tx, the shared `txHash` lets us reconstruct the "run" + // without any parent-pointer in the indexer. + const runsByTxHash = new Map(); + for (const child of indexed ? [] : chain) { + if (!child) { + continue; + } + for (const dispatch of child.dispatches) { + const existing = runsByTxHash.get(dispatch.txHash); + // For non-swap strategies dispatch.amount/token already describe + // the OUT leg. For swaps we prefer dispatch.tokenOut (REST metadata) + // so the chain leg doesn't read "IN WETH → IN WETH". + const tokenOut = dispatch.tokenOut ?? dispatch.token; + const amountOut = + dispatch.amountOut ?? + (dispatch.tokenOut && dispatch.tokenOut !== dispatch.token + ? 0 + : dispatch.amount); + const leg: IFlowOrchestratorLeg = { + policyId: child.id, + policyName: child.name, + strategy: child.strategy, + amountOut, + tokenOut, + amountIn: dispatch.amountIn, + tokenIn: dispatch.tokenIn, + recipientsCount: dispatch.recipientsCount, + status: dispatch.status ?? 'ok', + }; + if (existing) { + existing.legs.push(leg); + if ( + new Date(dispatch.at).getTime() > + new Date(existing.at).getTime() + ) { + existing.at = dispatch.at; + } + } else { + runsByTxHash.set(dispatch.txHash, { + id: `run-${dispatch.txHash}`, + at: dispatch.at, + txHash: dispatch.txHash, + legs: [leg], + }); + } + } + } + + const runs = + indexed?.runs ?? + Array.from(runsByTxHash.values()).sort( + (a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(), + ); + const latestIndexedSteps = indexed?.latestIndexedSteps; + + const installTxHash = envioPolicy.installTxHash; + const installAttribution = lookupProposal(proposalByTxHash, installTxHash); + const uninstallEvent = envioPolicy.events.find( + (e) => e.kind === 'UNINSTALLED', + ); + + const isUninstalled = envioPolicy.status === 'UNINSTALLED'; + const status: FlowPolicyStatus = isUninstalled + ? 'paused' + : runs.length > 0 + ? 'live' + : 'never'; + const statusLabel = isUninstalled + ? 'Uninstalled' + : runs.length > 0 + ? 'Live' + : 'Never dispatched'; + + const strategy: FlowPolicyStrategy = + STRATEGY_LABEL[envioPolicy.strategyType]; + + return { + id: envioPolicy.id, + address: envioPolicy.pluginAddress.toLowerCase(), + name: + restPolicy?.name ?? + `Multi-dispatch ${shortenAddress(envioPolicy.pluginAddress)}`, + description: + restPolicy?.description ?? + `${strategy} plugin installed on ${shortenAddress(envioPolicy.dao.address)}.`, + strategy, + status, + statusLabel, + createdAt: isoFromSeconds(envioPolicy.installedAt), + installedViaProposalId: installAttribution?.proposalId, + installedViaProposalSlug: installAttribution?.slug, + installedViaProposalTitle: installAttribution?.title, + installTxHash, + uninstalledAt: uninstallEvent + ? isoFromSeconds(uninstallEvent.blockTimestamp) + : undefined, + chain, + embeddedStrategies: mapEmbeddedStrategies(envioPolicy.strategies), + runs, + latestIndexedSteps, + lastRunAt: runs[0]?.at, + totalRuns: runs.length, + }; +}; + +// --------------------------------------------------------------------------- +// Grouping (active / neverRun / archived pills) +// --------------------------------------------------------------------------- + +const lastActivityTs = (policy: IFlowPolicy): number => { + if (policy.lastDispatch) { + return new Date(policy.lastDispatch.at).getTime(); + } + return new Date(policy.createdAt).getTime(); +}; + +export const groupPolicies = ( + policies: readonly IFlowPolicy[], +): IFlowGroupedPolicies => { + const active: IFlowPolicy[] = []; + const neverRun: IFlowPolicy[] = []; + const archived: IFlowPolicy[] = []; + + for (const policy of policies) { + if (policy.status === 'paused') { + archived.push(policy); + } else if (policy.status === 'never') { + neverRun.push(policy); + } else { + active.push(policy); + } + } + + active.sort((a, b) => lastActivityTs(b) - lastActivityTs(a)); + neverRun.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + archived.sort((a, b) => { + const ats = + (b.uninstalledAt + ? new Date(b.uninstalledAt).getTime() + : lastActivityTs(b)) - + (a.uninstalledAt + ? new Date(a.uninstalledAt).getTime() + : lastActivityTs(a)); + return ats; + }); + + return { active, neverRun, archived }; +}; + +// --------------------------------------------------------------------------- +// Recipients aggregate (dashboard-level) +// --------------------------------------------------------------------------- + +const mapRecipientsAggregate = ( + recipientAggregates: readonly IEnvioRecipientAggregate[], + envioPolicies: readonly IEnvioPolicy[], + addressBook: IFlowAddressBook, +): IFlowRecipientAggregate[] => { + const policyById = new Map(envioPolicies.map((p) => [p.id, p])); + const byRecipient = new Map(); + + for (const r of recipientAggregates) { + const sourcePolicy = policyById.get(r.policy.id); + const group = sourcePolicy + ? `${STRATEGY_LABEL[sourcePolicy.strategyType]}` + : 'Unknown'; + const key = r.recipient; + const existing = byRecipient.get(key); + const amount = normaliseAmount(r.totalAmount, r.token.decimals); + const tokenSymbol = r.token.symbol as FlowTokenSymbol; + if (existing) { + existing.amountsByToken[tokenSymbol] = + (existing.amountsByToken[tokenSymbol] ?? 0) + amount; + existing.dispatchCount += r.transferCount; + if ( + toMillis(r.lastAt) > new Date(existing.lastReceivedAt).getTime() + ) { + existing.lastReceivedAt = isoFromSeconds(r.lastAt); + } + if (!existing.fromPolicyIds.includes(r.policy.id)) { + existing.fromPolicyIds.push(r.policy.id); + } + } else { + const resolved = addressBook.resolve(r.recipient); + byRecipient.set(key, { + address: r.recipient, + name: resolved?.label ?? shortenAddress(r.recipient), + ens: resolved?.ens ?? undefined, + role: resolved?.role, + group, + fromPolicyIds: [r.policy.id], + amountsByToken: { [tokenSymbol]: amount } as Partial< + Record + >, + dispatchCount: r.transferCount, + lastReceivedAt: isoFromSeconds(r.lastAt), + }); + } + } + + return Array.from(byRecipient.values()).sort( + (a, b) => + new Date(b.lastReceivedAt).getTime() - + new Date(a.lastReceivedAt).getTime(), + ); +}; + +// --------------------------------------------------------------------------- +// Top-level mapper +// --------------------------------------------------------------------------- + +export interface IBuildFlowDataFromEnvioParams { + network: string; + addressOrEns: string; + daoDisplayName?: string; + indexerData: IFlowDaoDataResponse; + restPolicies: readonly IDaoPolicy[]; + linkedAccounts?: readonly ILinkedAccountSummary[]; + /** + * Map from lowercase transaction hash to governance-proposal attribution. Built in + * the provider from `useProposalList` so the mapper stays free of governance-service + * dependencies. Unset → events/policies get `proposalSlug === undefined`. + */ + proposalByTxHash?: ProposalByTxHash; + /** + * Synchronous address-name resolver assembled in the provider from DAO info, + * linked accounts, REST policies, and burn addresses. Used to replace the + * default `shortenAddress` rendering with a human label in 3 places: per-policy + * dispatch recipients, per-policy aggregate recipients, and the dashboard-wide + * recipient aggregate. Unset → falls back to the empty book (renders truncated). + */ + addressBook?: IFlowAddressBook; +} + +const buildDao = (params: IBuildFlowDataFromEnvioParams): IFlowDao => ({ + network: params.network, + addressOrEns: params.addressOrEns, + name: params.daoDisplayName ?? 'DAO', + avatarColor: '#003bf5', +}); + +export const buildFlowDataFromEnvio = ( + params: IBuildFlowDataFromEnvioParams, +): IFlowDaoData => { + const { + indexerData, + restPolicies, + proposalByTxHash, + addressBook = EMPTY_FLOW_ADDRESS_BOOK, + } = params; + + // Index every dispatcher's provenance FlowSteps by plugin address so each + // orchestrator gets its own (real-amount) run reconstruction. + const flowStepsByDispatcher = groupFlowStepsByDispatcher( + indexerData.FlowStep ?? [], + ); + + // Envio keys policies by (chainId:pluginAddress). REST keys by pluginAddress. Join on + // lowercase plugin address. + const restByPlugin = new Map(); + for (const p of restPolicies) { + restByPlugin.set(p.address.toLowerCase(), p); + } + + // Pass 1: map all policies (leaves + orchestrators) as leaf policies so the + // orchestrator builder can look children up by plugin address. + const allLeafPolicies = indexerData.Policy.map((envioPolicy) => + mapPolicy({ + envioPolicy, + restPolicy: restByPlugin.get( + envioPolicy.pluginAddress.toLowerCase(), + ), + recipientAggregates: indexerData.RecipientAggregate, + proposalByTxHash, + addressBook, + }), + ); + + const policiesByAddress = new Map(); + for (const p of allLeafPolicies) { + policiesByAddress.set(p.address.toLowerCase(), p); + } + + // Pass 2: split into orchestrators vs leaves. Orchestrators get a paired + // `IFlowPolicy` mirror so the policy-detail route can resolve their id — + // see `IFlowDaoData.orchestratorPolicies`. + const orchestrators: IFlowOrchestrator[] = []; + const orchestratorPolicies: IFlowPolicy[] = []; + const leafPolicies: IFlowPolicy[] = []; + for (const envioPolicy of indexerData.Policy) { + if (isOrchestratorStrategyType(envioPolicy.strategyType)) { + orchestrators.push( + buildOrchestrator({ + envioPolicy, + restPolicy: restByPlugin.get( + envioPolicy.pluginAddress.toLowerCase(), + ), + policiesByAddress, + proposalByTxHash, + flowSteps: + flowStepsByDispatcher.get( + envioPolicy.pluginAddress.toLowerCase(), + ) ?? [], + }), + ); + const mirror = policiesByAddress.get( + envioPolicy.pluginAddress.toLowerCase(), + ); + if (mirror) { + orchestratorPolicies.push(mirror); + } + } else { + const mapped = policiesByAddress.get( + envioPolicy.pluginAddress.toLowerCase(), + ); + if (mapped) { + leafPolicies.push(mapped); + } + } + } + + // Sort orchestrators: active first (by lastRunAt DESC), then never-run, archived last. + orchestrators.sort((a, b) => { + const aKey = a.status === 'paused' ? 0 : a.lastRunAt ? 2 : 1; + const bKey = b.status === 'paused' ? 0 : b.lastRunAt ? 2 : 1; + if (aKey !== bKey) { + return bKey - aKey; + } + const aTs = a.lastRunAt + ? new Date(a.lastRunAt).getTime() + : new Date(a.createdAt).getTime(); + const bTs = b.lastRunAt + ? new Date(b.lastRunAt).getTime() + : new Date(b.createdAt).getTime(); + return bTs - aTs; + }); + + // Back-compat: keep the flat `policies` list in install-order, like before (useful for + // the detail page + activity preview that iterates everything). + const policies = [...leafPolicies].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + const groupedPolicies = groupPolicies(leafPolicies); + + return { + dao: buildDao(params), + policies, + groupedPolicies, + orchestrators, + orchestratorPolicies, + recipients: mapRecipientsAggregate( + indexerData.RecipientAggregate, + indexerData.Policy, + addressBook, + ), + }; +}; + +// Re-exported for the provider to detect "empty indexer state" and fall back to a hint UI. +export { DAY_MS }; + +// Suppress unused imports warning for PolicyStrategyType if tsc ever removes the discriminant. +void PolicyStrategyType; diff --git a/src/modules/flow/utils/flowAddressBook.ts b/src/modules/flow/utils/flowAddressBook.ts new file mode 100644 index 0000000000..f8c30eda22 --- /dev/null +++ b/src/modules/flow/utils/flowAddressBook.ts @@ -0,0 +1,198 @@ +/** + * Client-side address → name resolver for the Flow module. + * + * Built from the pieces we already have in memory: + * 1. The DAO itself (`dao.name`, `dao.ens`, `dao.avatar`). + * 2. The DAO's linked accounts (child DAOs / treasury spokes). + * 3. REST policies returned by `/v2/policies` (so router / sub-router addresses + * render as "USDC stream" instead of `0x12…abcd`). + * 4. Well-known burn addresses (zero + the `0xdead` sentinel). + * + * This mirrors the backend `AddressMapper` used by the dispatch-simulation feature, + * but runs on the client so the Flow dashboard doesn't need an extra backend roundtrip + * to hydrate names. ENS lookups for unknown addresses are handled separately at + * render time (see `FlowAddressLabel`), so this resolver is intentionally synchronous. + */ + +import type { + IDaoPolicy, + ILinkedAccountSummary, +} from '@/shared/api/daoService'; + +export type FlowAddressRole = + | 'dao' + | 'linkedaccount' + | 'router' + | 'subrouter' + | 'burn'; + +export interface IFlowAddressBookEntry { + /** + * Human-readable label shown in place of the raw address. Guaranteed non-empty. + */ + label: string; + role: FlowAddressRole; + /** + * Optional avatar URL (typically only available for DAOs / linked accounts). + */ + avatar?: string | null; + /** + * ENS name when known ahead of time (e.g. DAO ENS set in REST). Distinct from + * the ENS resolved asynchronously by `useEnsName`. + */ + ens?: string | null; + /** + * Additional context surfaced in the UI under the label when non-empty — + * e.g. the `policyKey` of a REST policy ("router · drainRatioRouter"). + */ + subtitle?: string; +} + +export interface IFlowAddressBook { + /** + * Returns the known entry for the given EVM address (case-insensitive), or + * `undefined` if the address isn't in the book. The caller is expected to fall + * back to ENS / truncate rendering in that case. + */ + resolve: (address: string) => IFlowAddressBookEntry | undefined; + /** + * Number of entries in the book — exposed for debugging / memoization keys. + */ + size: number; +} + +const BURN_ADDRESSES: Record = { + '0x0000000000000000000000000000000000000000': 'Zero address', + '0x000000000000000000000000000000000000dead': 'Burn address', +}; + +/** + * Well-known protocol addresses (DAO-agnostic) that the indexer doesn't name on + * its own — e.g. parent treasuries that receive a flow's proceeds. Generic + * registry, not per-flow: any flow whose output lands here renders the friendly + * name instead of a raw address. Extend as more are encountered. + */ +const WELL_KNOWN_ADDRESSES: Record< + string, + { label: string; role: FlowAddressRole } +> = { + // Lido DAO Aragon Agent (mainnet treasury / sweep authority). + '0x3e40d73eb977dc6a537af587d48316fee66e9c8c': { + label: 'Lido Agent', + role: 'linkedaccount', + }, +}; + +export interface IFlowAddressBookSourceDao { + address: string; + name?: string | null; + ens?: string | null; + avatar?: string | null; +} + +export interface IBuildFlowAddressBookParams { + dao?: IFlowAddressBookSourceDao; + linkedAccounts?: readonly ILinkedAccountSummary[]; + restPolicies?: readonly IDaoPolicy[]; +} + +const normalizeAddress = (address: string): string => address.toLowerCase(); + +const safeLabel = ( + candidate: string | null | undefined, + fallback: string, +): string => { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + return fallback; +}; + +/** + * Build a synchronous address → label map from the DAO's known context. The + * returned book is stable for a given set of inputs — wrap the call in + * `useMemo` if you care about referential equality. + */ +export const buildFlowAddressBook = ( + params: IBuildFlowAddressBookParams, +): IFlowAddressBook => { + const entries = new Map(); + + // Burn + well-known protocol addresses take the lowest precedence so a DAO's + // own labels (REST policies / linked accounts) always win on a collision. + for (const [addr, label] of Object.entries(BURN_ADDRESSES)) { + entries.set(normalizeAddress(addr), { + label, + role: 'burn', + }); + } + for (const [addr, { label, role }] of Object.entries( + WELL_KNOWN_ADDRESSES, + )) { + entries.set(normalizeAddress(addr), { label, role }); + } + + // REST policies — each plugin address gets a label like "Salary stream". We + // add these before the DAO + linked accounts so the DAO's address can + // override a rare collision (shouldn't happen, but cheap safety). + for (const policy of params.restPolicies ?? []) { + if (!policy.address) { + continue; + } + const role: FlowAddressRole = + (policy.strategy?.subRouters?.length ?? 0) > 0 + ? 'router' + : 'subrouter'; + const label = safeLabel( + policy.name, + `${policy.policyKey ?? role} plugin`, + ); + entries.set(normalizeAddress(policy.address), { + label, + role, + subtitle: policy.policyKey ?? undefined, + }); + } + + if (params.dao) { + const { address, name, ens, avatar } = params.dao; + if (address) { + entries.set(normalizeAddress(address), { + label: safeLabel(name, safeLabel(ens, 'DAO')), + role: 'dao', + avatar: avatar ?? undefined, + ens: ens ?? undefined, + }); + } + } + + for (const linked of params.linkedAccounts ?? []) { + if (!linked.address) { + continue; + } + entries.set(normalizeAddress(linked.address), { + label: safeLabel(linked.name, safeLabel(linked.ens, 'Linked DAO')), + role: 'linkedaccount', + avatar: linked.avatar ?? undefined, + ens: linked.ens ?? undefined, + }); + } + + return { + resolve: (address: string) => { + if (!address) { + return undefined; + } + return entries.get(normalizeAddress(address)); + }, + size: entries.size, + }; +}; + +/** + * Empty book — useful as a default so mappers don't have to guard against + * `undefined` every time they call `resolve`. + */ +export const EMPTY_FLOW_ADDRESS_BOOK: IFlowAddressBook = buildFlowAddressBook( + {}, +); diff --git a/src/modules/flow/utils/flowFormatters.ts b/src/modules/flow/utils/flowFormatters.ts new file mode 100644 index 0000000000..0becbbc974 --- /dev/null +++ b/src/modules/flow/utils/flowFormatters.ts @@ -0,0 +1,95 @@ +import { + FLOW_TOKEN_FALLBACK_COLOR, + FLOW_TOKENS, + type FlowPolicyStrategy, + type FlowTokenSymbol, + type IFlowToken, +} from '../types'; + +const getFlowToken = (symbol: FlowTokenSymbol): IFlowToken => + FLOW_TOKENS[symbol] ?? { + symbol, + color: FLOW_TOKEN_FALLBACK_COLOR, + decimals: 2, + }; + +/** + * Pull-based strategies never produce a push-style "Dispatch now" action — + * funds move when users claim or when the router settles autonomously. The + * UI must suppress the dispatch button for these strategies even if the + * policy is otherwise in a `ready`-like status. + */ +export const isDispatchableStrategy = (strategy: FlowPolicyStrategy): boolean => + strategy !== 'Claimer'; + +export const formatFlowAmount = ( + value: number, + token: FlowTokenSymbol, +): string => { + if (value === 0) { + return '0'; + } + const abs = Math.abs(value); + const tokenMeta = getFlowToken(token); + // Show two decimals for high-precision tokens (e.g. WETH), integers otherwise. + const decimals = tokenMeta.decimals >= 3 ? 2 : 0; + if (abs >= 1_000_000) { + return `${(value / 1_000_000) + .toFixed(value >= 10_000_000 ? 1 : 2) + .replace(/\.?0+$/, '')}M`; + } + if (abs >= 100_000) { + return `${(value / 1000).toFixed(0)}k`; + } + return value.toLocaleString('en-US', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +}; + +export const formatFlowAmountWithToken = ( + value: number, + token: FlowTokenSymbol, +): string => `${formatFlowAmount(value, token)} ${token}`; + +export const formatRelative = (isoDate: string, now = Date.now()): string => { + const then = new Date(isoDate).getTime(); + const delta = now - then; + const absDelta = Math.abs(delta); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + const week = 7 * day; + const month = 30 * day; + let value: number; + let unit: string; + if (absDelta < hour) { + value = Math.max(1, Math.round(absDelta / minute)); + unit = 'm'; + } else if (absDelta < day) { + value = Math.round(absDelta / hour); + unit = 'h'; + } else if (absDelta < week * 4) { + value = Math.round(absDelta / day); + unit = 'd'; + } else if (absDelta < month * 12) { + value = Math.round(absDelta / month); + unit = 'mo'; + } else { + value = Math.round(absDelta / (month * 12)); + unit = 'y'; + } + return delta >= 0 ? `${value}${unit} ago` : `in ${value}${unit}`; +}; + +export const formatShortDate = (isoDate: string): string => { + const date = new Date(isoDate); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +}; + +export const getTokenColor = (token: FlowTokenSymbol): string => + getFlowToken(token).color; diff --git a/src/shared/api/daoService/queries/useDao/useDao.ts b/src/shared/api/daoService/queries/useDao/useDao.ts index 9f6ea09a8c..77f3465153 100644 --- a/src/shared/api/daoService/queries/useDao/useDao.ts +++ b/src/shared/api/daoService/queries/useDao/useDao.ts @@ -1,4 +1,9 @@ import { useQuery } from '@tanstack/react-query'; +// LMM_DEMO_HACK: see app/src/modules/flow/demo/lmmDemoConfig.ts. In demo +// mode for the Lido Money Machine DAO we bypass the Aragon hosted subgraph +// and synthesise IDao from the manifest + Envio. Remove this import + the +// override branch in queryFn to drop demo behaviour. +import { tryLmmDaoOverride } from '@/modules/flow/demo/lmmDaoOverride'; import type { QueryOptions, SharedQueryOptions } from '@/shared/types'; import { daoService } from '../../daoService'; import type { IGetDaoParams } from '../../daoService.api'; @@ -10,7 +15,13 @@ export const daoOptions = ( options?: QueryOptions, ): SharedQueryOptions => ({ queryKey: daoServiceKeys.dao(params), - queryFn: () => daoService.getDao(params), + queryFn: async () => { + const override = await tryLmmDaoOverride(params.urlParams.id); + if (override) { + return override; + } + return daoService.getDao(params); + }, ...options, }); diff --git a/src/shared/api/daoService/queries/useDaoByEns/useDaoByEns.ts b/src/shared/api/daoService/queries/useDaoByEns/useDaoByEns.ts index 654ff52233..f2b4f1900f 100644 --- a/src/shared/api/daoService/queries/useDaoByEns/useDaoByEns.ts +++ b/src/shared/api/daoService/queries/useDaoByEns/useDaoByEns.ts @@ -1,4 +1,6 @@ import { useQuery } from '@tanstack/react-query'; +// LMM_DEMO_HACK: see app/src/modules/flow/demo/lmmDemoConfig.ts. +import { tryLmmDaoByEnsOverride } from '@/modules/flow/demo/lmmDaoOverride'; import type { QueryOptions, SharedQueryOptions } from '@/shared/types'; import { daoService } from '../../daoService'; import type { IGetDaoByEnsParams } from '../../daoService.api'; @@ -10,7 +12,16 @@ export const daoByEnsOptions = ( options?: QueryOptions, ): SharedQueryOptions => ({ queryKey: daoServiceKeys.daoByEns(params), - queryFn: () => daoService.getDaoByEns(params), + queryFn: async () => { + const override = await tryLmmDaoByEnsOverride( + params.urlParams.network, + params.urlParams.ens, + ); + if (override) { + return override; + } + return daoService.getDaoByEns(params); + }, ...options, }); diff --git a/src/shared/api/daoService/queries/useDaoPermissions/useDaoPermissions.ts b/src/shared/api/daoService/queries/useDaoPermissions/useDaoPermissions.ts index d57b951e00..1f3f8eee8f 100644 --- a/src/shared/api/daoService/queries/useDaoPermissions/useDaoPermissions.ts +++ b/src/shared/api/daoService/queries/useDaoPermissions/useDaoPermissions.ts @@ -1,4 +1,6 @@ import { useInfiniteQuery } from '@tanstack/react-query'; +// LMM_DEMO_HACK: see app/src/modules/flow/demo/lmmDemoConfig.ts. +import { tryLmmDaoPermissionsOverride } from '@/modules/flow/demo/lmmDaoOverride'; import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; import type { InfiniteQueryOptions, @@ -21,7 +23,15 @@ export const daoPermissionsOptions = ( > => ({ queryKey: daoServiceKeys.daoPermissions(params), initialPageParam: params, - queryFn: ({ pageParam }) => daoService.getDaoPermissions(pageParam), + queryFn: async ({ pageParam }) => { + const override = await tryLmmDaoPermissionsOverride( + pageParam.urlParams.daoAddress, + ); + if (override) { + return override; + } + return daoService.getDaoPermissions(pageParam); + }, getNextPageParam: daoService.getNextPageParams, ...options, }); diff --git a/src/shared/api/daoService/queries/useDaoPolicies/useDaoPolicies.ts b/src/shared/api/daoService/queries/useDaoPolicies/useDaoPolicies.ts index 7ed7964f83..be3ecfda18 100644 --- a/src/shared/api/daoService/queries/useDaoPolicies/useDaoPolicies.ts +++ b/src/shared/api/daoService/queries/useDaoPolicies/useDaoPolicies.ts @@ -1,4 +1,6 @@ import { useQuery } from '@tanstack/react-query'; +// LMM_DEMO_HACK: see app/src/modules/flow/demo/lmmDemoConfig.ts. +import { tryLmmDaoPoliciesOverride } from '@/modules/flow/demo/lmmDaoOverride'; import type { QueryOptions, SharedQueryOptions } from '@/shared/types'; import { daoService } from '../../daoService'; import type { IGetDaoPoliciesParams } from '../../daoService.api'; @@ -10,7 +12,15 @@ export const daoPoliciesOptions = ( options?: QueryOptions, ): SharedQueryOptions => ({ queryKey: daoServiceKeys.daoPolicies(params), - queryFn: () => daoService.getDaoPolicies(params), + queryFn: async () => { + const override = await tryLmmDaoPoliciesOverride( + params.urlParams.daoAddress, + ); + if (override) { + return override; + } + return daoService.getDaoPolicies(params); + }, ...options, }); diff --git a/src/shared/api/flowIndexer/flowIndexerClient.ts b/src/shared/api/flowIndexer/flowIndexerClient.ts new file mode 100644 index 0000000000..9009a78a1c --- /dev/null +++ b/src/shared/api/flowIndexer/flowIndexerClient.ts @@ -0,0 +1,82 @@ +/** + * Minimal GraphQL fetch client for the capital-flow-indexer (Envio/Hasura endpoint). + * We intentionally avoid adding `graphql-request` as a dependency — the app already ships + * with native `fetch`, and our single-query usage doesn't justify another 30kB in the bundle. + * + * Endpoint is configured via `NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT`. When unset, callers should + * gate the feature flag so this client is never invoked. + */ + +export class FlowIndexerError extends Error { + constructor( + message: string, + readonly status?: number, + readonly errors?: unknown, + ) { + super(message); + this.name = 'FlowIndexerError'; + } +} + +export interface IFlowIndexerRequestOptions { + signal?: AbortSignal; +} + +interface IGraphQLResponse { + data?: T; + errors?: Array<{ message: string; path?: readonly (string | number)[] }>; +} + +export const getFlowIndexerEndpoint = (): string | undefined => + process.env.NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT; + +/** + * Returns `true` when the feature flag **and** the endpoint are both configured. The Flow + * dashboard uses this to decide whether to mount the Envio-backed provider branch. + */ +export const isFlowIndexerEnabled = (): boolean => + process.env.NEXT_PUBLIC_FLOW_USE_ENVIO === 'true' && + Boolean(getFlowIndexerEndpoint()); + +export const flowIndexerRequest = async < + TData, + TVariables extends Record, +>( + query: string, + variables: TVariables, + options: IFlowIndexerRequestOptions = {}, +): Promise => { + const endpoint = getFlowIndexerEndpoint(); + if (!endpoint) { + throw new FlowIndexerError( + 'NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT is not set — cannot query Envio.', + ); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + signal: options.signal, + }); + + if (!response.ok) { + throw new FlowIndexerError( + `Flow indexer responded with HTTP ${response.status}`, + response.status, + ); + } + + const payload = (await response.json()) as IGraphQLResponse; + if (payload.errors && payload.errors.length > 0) { + throw new FlowIndexerError( + `Flow indexer query failed: ${payload.errors.map((e) => e.message).join('; ')}`, + response.status, + payload.errors, + ); + } + if (!payload.data) { + throw new FlowIndexerError('Flow indexer returned no data'); + } + return payload.data; +}; diff --git a/src/shared/api/flowIndexer/flowIndexerKeys.ts b/src/shared/api/flowIndexer/flowIndexerKeys.ts new file mode 100644 index 0000000000..5d30d04520 --- /dev/null +++ b/src/shared/api/flowIndexer/flowIndexerKeys.ts @@ -0,0 +1,11 @@ +export enum FlowIndexerKey { + DAO_DATA = 'FLOW_INDEXER_DAO_DATA', +} + +export const flowIndexerKeys = { + daoData: (params: { + chainId: number; + daoIds: string[]; + executionLimit: number; + }) => [FlowIndexerKey.DAO_DATA, params] as const, +}; diff --git a/src/shared/api/flowIndexer/flowIndexerTypes.ts b/src/shared/api/flowIndexer/flowIndexerTypes.ts new file mode 100644 index 0000000000..6bc0d2d06f --- /dev/null +++ b/src/shared/api/flowIndexer/flowIndexerTypes.ts @@ -0,0 +1,312 @@ +/** + * Shape of the responses returned by the capital-flow-indexer (Envio/Hasura). + * Mirrors `schema.graphql` in `capital-flow-indexer/`. + * + * Raw token amounts are returned as strings by Hasura (BigInt-safe); callers must parse + * them to BigInt before doing arithmetic, or to number after normalising by `token.decimals`. + */ + +export interface IEnvioToken { + id: string; + address: string; + symbol: string; + decimals: number; +} + +export type EnvioTransferDecodedFrom = + | 'transfer' + | 'transferFrom' + | 'native' + | 'swapIn' + | 'wrap' + | 'univ2AddLiquidity' + | 'swapPresign' + | 'approve' + | 'unknown'; + +export interface IEnvioExecutionTransfer { + id: string; + amount: string; + to: string; + decodedFrom: EnvioTransferDecodedFrom; + actionIndex: number; + token: IEnvioToken; +} + +export type EnvioExecutionKind = 'DISPATCH' | 'CLAIM' | 'DISPATCH_SKIPPED'; + +export interface IEnvioPolicyExecution { + id: string; + kind: EnvioExecutionKind; + blockNumber: string; + blockTimestamp: string; + txHash: string; + logIndex: number; + transferCount: number; + decodedTransferCount: number; + transfers: IEnvioExecutionTransfer[]; + // Multi-dispatch context — present when the parent policy is a DispatcherPlugin. + strategyIndex?: number | null; + strategy?: IEnvioStrategyRef | null; + skipped?: boolean | null; + skippedReason?: string | null; + swapOrders?: IEnvioSwapOrder[]; +} + +/** + * Reference to an embedded strategy when loaded via a PolicyExecution. The + * full Strategy entity (with budget/gate/etc) is queried separately via + * `Policy.strategies`. + */ +export interface IEnvioStrategyRef { + id: string; + address: string; + kind: EnvioEmbeddedStrategyKind; + index?: number | null; +} + +export type EnvioEmbeddedStrategyKind = + | 'WRAP' + | 'UNIV2_LIQUIDITY' + | 'GATED_COWSWAP' + | 'COWSWAP' + | 'TRANSFER' + | 'EPOCH_TRANSFER' + | 'BURN' + | 'UNKNOWN'; + +export type EnvioBudgetKind = 'STREAM_UNTIL' | 'FULL' | 'REQUIRED' | 'UNKNOWN'; +export type EnvioGateKind = 'PRICE_FLOOR' | 'UNKNOWN'; + +export interface IEnvioBudget { + id: string; + address: string; + kind: EnvioBudgetKind; + floorEpochs?: string | null; + targetEpoch?: string | null; + epochProviderAddress?: string | null; + initializedAt: string; +} + +export interface IEnvioGate { + id: string; + address: string; + kind: EnvioGateKind; + threshold?: string | null; + maxStaleness?: string | null; + oracle?: string | null; + baseToken?: string | null; + quoteToken?: string | null; +} + +export interface IEnvioEpochProvider { + id: string; + address: string; + epochLength?: string | null; +} + +export interface IEnvioStrategy { + id: string; + address: string; + kind: EnvioEmbeddedStrategyKind; + index?: number | null; + configJson?: string | null; + paused: boolean; + budget?: IEnvioBudget | null; + gate?: IEnvioGate | null; + epochProvider?: IEnvioEpochProvider | null; +} + +/** + * CowSwap pre-signed order, attached to a PolicyExecution + Strategy. + */ +export interface IEnvioSwapOrder { + id: string; + orderUid: string; + sellToken: IEnvioToken; + buyToken: IEnvioToken; + sellAmount: string; + buyAmount: string; + validTo: string; + feeAmount: string; + postedAt: string; + strategy?: Pick; +} + +export type EnvioPolicyEventKind = + | 'INSTALLED' + | 'INITIALIZED' + | 'UNINSTALLED' + | 'SETTINGS_UPDATED' + | 'FAILSAFE_UPDATED' + | 'STRATEGY_FAILED' + | 'STRATEGY_PAUSED' + | 'STRATEGY_UNPAUSED' + | 'TARGET_EPOCH_UPDATED' + | 'FLOOR_EPOCHS_UPDATED' + | 'EPOCH_LENGTH_UPDATED' + | 'GATE_THRESHOLD_UPDATED' + | 'GATE_STALENESS_UPDATED' + | 'COWSWAP_SETTINGS_UPDATED'; + +export interface IEnvioPolicyEvent { + id: string; + kind: EnvioPolicyEventKind; + blockTimestamp: string; + txHash: string; + description: string; + contextJson?: string | null; +} + +export type EnvioPolicyStatus = 'NEVER_RUN' | 'RUNNING' | 'UNINSTALLED'; + +/** + * Matches `IPolicyStrategyType` on the backend + `REPO_TO_STRATEGY` on the indexer. + */ +export type EnvioStrategyType = + | 'router' + | 'burnRouter' + | 'claimer' + | 'multiDispatch' + | 'multiRouter' + | 'multiClaimer' + | 'uniswapRouter' + | 'cowSwapRouter' + // New (Lido demo) — embedded strategy kinds, surface only when standalone. + | 'wrapStrategy' + | 'univ2LiquidityStrategy' + | 'gatedCowSwapStrategy' + | 'transferStrategy' + | 'epochTransferStrategy' + | 'burnStrategy'; + +export interface IEnvioDao { + id: string; + address: string; + chainId: string; + name?: string | null; + description?: string | null; + avatarUrl?: string | null; +} + +export interface IEnvioPolicy { + id: string; + pluginAddress: string; + strategyType: EnvioStrategyType; + pluginId?: string | null; + pluginSetupRepo: string; + status: EnvioPolicyStatus; + installedAt: string; + installTxHash: string; + installBlockNumber: string; + totalDispatches: string; + lastDispatchAt?: string | null; + dao: IEnvioDao; + lastExecution?: IEnvioPolicyExecution | null; + executions: IEnvioPolicyExecution[]; + events: IEnvioPolicyEvent[]; + // For multi-dispatch / dispatcher policies — the embedded strategies, in + // dispatch order. Empty for legacy single-dispatch routers + claimers. + strategies?: IEnvioStrategy[]; +} + +export interface IEnvioRecipientAggregate { + id: string; + recipient: string; + totalAmount: string; + transferCount: number; + firstAt: string; + lastAt: string; + token: IEnvioToken; + policy: { + id: string; + pluginAddress: string; + strategyType: EnvioStrategyType; + }; + dao: Pick; +} + +// --------------------------------------------------------------------------- +// Provenance-tagged flow graph (FlowStep / FlowEdge / SwapFill) +// --------------------------------------------------------------------------- + +/** + * How the indexer classifies an amount's trustworthiness. Aligned 1:1 with + * `enum Provenance` in `capital-flow-indexer/schema.graphql` and with the + * vendored `lido-preview` Provenance. The app collapses these to a + * three-level {@link FlowFidelity} (`real` / `estimated` / `opaque`). + */ +export type EnvioProvenance = + | 'DETERMINISTIC' + | 'ONCHAIN_EVENT' + | 'SETTLED' + | 'ESTIMATED_VIA_QUOTER' + | 'ESTIMATED_VIA_ORACLE' + | 'OPAQUE'; + +/** + * Role of a {@link IEnvioFlowEdge} — lets the canvas infer topology without + * re-decoding calldata: + * VAULT_OUT — vault → strategy/router (budget spent, swap sell, LP input). + * VAULT_IN — produced back to the vault (wrap output, LP minted, swap fill). + * EXTERNAL — vault → a real recipient (transfer/native to a non-vault addr). + */ +export type EnvioFlowEdgeRole = 'VAULT_OUT' | 'VAULT_IN' | 'EXTERNAL'; + +export interface IEnvioFlowEdge { + id: string; + role: EnvioFlowEdgeRole; + token: IEnvioToken; + from: string; + to: string; + /** Raw units. `0` + provenance `OPAQUE` while still unknown (LP pre-Mint). */ + amount: string; + provenance: EnvioProvenance; + /** `decodedFrom` plus `wrapOut` | `univ2LpMinted` | `swapFill`. */ + decodedFrom: string; + seq: number; + pending: boolean; +} + +export type EnvioFlowStepStatus = + | 'EXECUTED' + | 'NO_OP' + | 'SKIPPED_PAUSED' + | 'SKIPPED_GATED' + | 'FAILED' + | 'OPAQUE'; + +/** + * Per-leg provenance node the workbench canvas PROJECTS (rather than + * reconstructing the graph with heuristics). One per DispatchHandled leg + * (EXECUTED / NO_OP) and one per StrategyFailed leg (SKIPPED_* / FAILED). + */ +export interface IEnvioFlowStep { + id: string; + index: number; + kind: EnvioEmbeddedStrategyKind; + status: EnvioFlowStepStatus; + reason?: string | null; + /** Weakest provenance across this step's edges. */ + provenance: EnvioProvenance; + /** True while any edge amount is still unsettled (CoW order awaiting fill). */ + pending: boolean; + blockTimestamp: string; + txHash: string; + /** `${chainId}:${pluginAddress}` of the dispatcher policy. */ + dispatcher: { id: string; pluginAddress: string }; + strategy?: IEnvioStrategyRef | null; + execution?: { id: string; txHash: string } | null; + edges: IEnvioFlowEdge[]; +} + +export interface IFlowDaoDataResponse { + Policy: IEnvioPolicy[]; + RecipientAggregate: IEnvioRecipientAggregate[]; + /** + * Provenance-tagged flow steps for every dispatcher in the DAO set, newest + * first. Grouped client-side by dispatcher + `txHash` into orchestrator + * runs (see `envioFlowMapper.ts`). Empty until a dispatcher has run. + */ + FlowStep: IEnvioFlowStep[]; +} diff --git a/src/shared/api/flowIndexer/index.ts b/src/shared/api/flowIndexer/index.ts new file mode 100644 index 0000000000..92e7ae7995 --- /dev/null +++ b/src/shared/api/flowIndexer/index.ts @@ -0,0 +1,37 @@ +export { + FlowIndexerError, + flowIndexerRequest, + getFlowIndexerEndpoint, + isFlowIndexerEnabled, +} from './flowIndexerClient'; +export { FlowIndexerKey, flowIndexerKeys } from './flowIndexerKeys'; +export type { + EnvioBudgetKind, + EnvioEmbeddedStrategyKind, + EnvioExecutionKind, + EnvioFlowEdgeRole, + EnvioFlowStepStatus, + EnvioGateKind, + EnvioPolicyEventKind, + EnvioPolicyStatus, + EnvioProvenance, + EnvioStrategyType, + EnvioTransferDecodedFrom, + IEnvioBudget, + IEnvioDao, + IEnvioEpochProvider, + IEnvioExecutionTransfer, + IEnvioFlowEdge, + IEnvioFlowStep, + IEnvioGate, + IEnvioPolicy, + IEnvioPolicyEvent, + IEnvioPolicyExecution, + IEnvioRecipientAggregate, + IEnvioStrategy, + IEnvioStrategyRef, + IEnvioSwapOrder, + IEnvioToken, + IFlowDaoDataResponse, +} from './flowIndexerTypes'; +export { useFlowIndexerDaoData } from './queries/useFlowIndexerDaoData'; diff --git a/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts b/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts new file mode 100644 index 0000000000..96694067bf --- /dev/null +++ b/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts @@ -0,0 +1,265 @@ +import { useQuery } from '@tanstack/react-query'; +import { flowIndexerRequest } from '../flowIndexerClient'; +import { flowIndexerKeys } from '../flowIndexerKeys'; +import type { IFlowDaoDataResponse } from '../flowIndexerTypes'; + +/** + * Pulls every policy + execution + transfer + recipient aggregate for the given DAO set. + * + * `daoIds` is the cross-product of { chainId, daoAddress }, composed client-side from the + * primary DAO + its `linkedAccounts`. We send lower-cased `chainId:address` strings because + * that's exactly how the indexer keys `Dao.id`. + */ + +// Reusable execution fragment. Inlined as a GraphQL fragment string because +// Envio/Hasura supports `fragment ... on Type { ... }` natively and using one +// in two places (lastExecution + executions) keeps the query short. +const EXECUTION_FIELDS = /* GraphQL */ ` + fragment ExecutionFields on PolicyExecution { + id + kind + blockNumber + blockTimestamp + txHash + logIndex + transferCount + decodedTransferCount + strategyIndex + skipped + skippedReason + strategy { + id + address + kind + index + } + transfers(order_by: { actionIndex: asc }) { + id + amount + to + decodedFrom + actionIndex + token { + id + address + symbol + decimals + } + } + swapOrders(order_by: { postedAt: asc }) { + id + orderUid + sellAmount + buyAmount + validTo + feeAmount + postedAt + sellToken { + id + address + symbol + decimals + } + buyToken { + id + address + symbol + decimals + } + strategy { + id + address + kind + } + } + } +`; + +const FLOW_DAO_DATA_QUERY = /* GraphQL */ ` + ${EXECUTION_FIELDS} + query FlowDaoData($daoIds: [String!]!, $executionLimit: Int!) { + Policy(where: { dao_id: { _in: $daoIds } }, order_by: { installedAt: desc }) { + id + pluginAddress + strategyType + pluginId + pluginSetupRepo + status + installedAt + installTxHash + installBlockNumber + totalDispatches + lastDispatchAt + dao { + id + address + chainId + name + description + avatarUrl + } + lastExecution { + ...ExecutionFields + } + executions( + order_by: { blockTimestamp: desc } + limit: $executionLimit + ) { + ...ExecutionFields + } + strategies(order_by: { index: asc }) { + id + address + kind + index + paused + configJson + budget { + id + address + kind + floorEpochs + targetEpoch + epochProviderAddress + initializedAt + } + gate { + id + address + kind + threshold + maxStaleness + oracle + baseToken + quoteToken + } + epochProvider { + id + address + epochLength + } + } + events(order_by: { blockTimestamp: desc }, limit: 25) { + id + kind + blockTimestamp + txHash + description + contextJson + } + } + RecipientAggregate( + where: { dao_id: { _in: $daoIds } } + order_by: { lastAt: desc } + limit: 200 + ) { + id + recipient + totalAmount + transferCount + firstAt + lastAt + token { + id + address + symbol + decimals + } + policy { + id + pluginAddress + strategyType + } + dao { + id + address + } + } + FlowStep( + where: { dispatcher: { dao_id: { _in: $daoIds } } } + order_by: { blockTimestamp: desc } + limit: $executionLimit + ) { + id + index + kind + status + reason + provenance + pending + blockTimestamp + txHash + dispatcher { + id + pluginAddress + } + strategy { + id + address + kind + index + } + execution { + id + txHash + } + edges(order_by: { seq: asc }) { + id + role + from + to + amount + provenance + decodedFrom + seq + pending + token { + id + address + symbol + decimals + } + } + } + } +`; + +export interface IUseFlowIndexerDaoDataParams { + chainId: number; + daoIds: string[]; + executionLimit?: number; + enabled?: boolean; + /** + * When `true` the hook polls the indexer every few seconds instead of every 30s. + * Intended for the window right after the user broadcasts an on-chain dispatch — + * the FlowDataProvider flips this on until the tx hash lands in `executions[]`. + */ + isUrgent?: boolean; +} + +const URGENT_REFETCH_INTERVAL_MS = 3000; +const IDLE_REFETCH_INTERVAL_MS = 30_000; + +export const useFlowIndexerDaoData = (params: IUseFlowIndexerDaoDataParams) => { + const { + chainId, + daoIds, + executionLimit = 40, + enabled = true, + isUrgent = false, + } = params; + + return useQuery({ + queryKey: flowIndexerKeys.daoData({ chainId, daoIds, executionLimit }), + queryFn: ({ signal }) => + flowIndexerRequest< + IFlowDaoDataResponse, + { daoIds: string[]; executionLimit: number } + >(FLOW_DAO_DATA_QUERY, { daoIds, executionLimit }, { signal }), + enabled: enabled && daoIds.length > 0, + staleTime: isUrgent ? 0 : 15_000, + refetchInterval: isUrgent + ? URGENT_REFETCH_INTERVAL_MS + : IDLE_REFETCH_INTERVAL_MS, + }); +}; diff --git a/src/shared/lidoPreview/abi/generated/BurnDispatchStrategy.ts b/src/shared/lidoPreview/abi/generated/BurnDispatchStrategy.ts new file mode 100644 index 0000000000..80b695baec --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/BurnDispatchStrategy.ts @@ -0,0 +1,241 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/BurnDispatchStrategy.sol/BurnDispatchStrategy.json + +export const burnDispatchStrategyAbi = [ + { + "type": "function", + "name": "MANAGER_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PREPARE_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchBudget" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "dao", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDAO" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_dao", + "type": "address", + "internalType": "contract IDAO" + }, + { + "name": "_budget", + "type": "address", + "internalType": "contract IDispatchBudget" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isStreamingBudget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "prepareActions", + "inputs": [], + "outputs": [ + { + "name": "_actions", + "type": "tuple[]", + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPaused", + "inputs": [ + { + "name": "_paused", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "strategyId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "CannotBurnNativeToken", + "inputs": [] + }, + { + "type": "error", + "name": "DaoUnauthorized", + "inputs": [ + { + "name": "dao", + "type": "address", + "internalType": "address" + }, + { + "name": "where", + "type": "address", + "internalType": "address" + }, + { + "name": "who", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "NoBudget", + "inputs": [] + }, + { + "type": "error", + "name": "StrategyPaused", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/DispatcherPlugin.ts b/src/shared/lidoPreview/abi/generated/DispatcherPlugin.ts new file mode 100644 index 0000000000..1c169b4456 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/DispatcherPlugin.ts @@ -0,0 +1,607 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/DispatcherPlugin.sol/DispatcherPlugin.json + +export const dispatcherPluginAbi = [ + { + "type": "function", + "name": "DISPATCH_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MANAGER_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SET_METADATA_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "SET_TARGET_CONFIG_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "atomicPrepareAndExecute", + "inputs": [ + { + "name": "_strategyIndex", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_executionId", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "_actions", + "type": "tuple[]", + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "dao", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDAO" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "dispatch", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "failsafeStrategyMap", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getCurrentTargetConfig", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct IPlugin.TargetConfig", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "operation", + "type": "uint8", + "internalType": "enum IPlugin.Operation" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getMetadata", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getTargetConfig", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct IPlugin.TargetConfig", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "operation", + "type": "uint8", + "internalType": "enum IPlugin.Operation" + } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_dao", + "type": "address", + "internalType": "contract IDAO" + }, + { + "name": "_strategies", + "type": "address[]", + "internalType": "contract IDispatchStrategy[]" + }, + { + "name": "_pluginMetadata", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "pluginId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "pluginType", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enum IPlugin.PluginType" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "protocolVersion", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8[3]", + "internalType": "uint8[3]" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "setMetadata", + "inputs": [ + { + "name": "_metadata", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setTargetConfig", + "inputs": [ + { + "name": "_targetConfig", + "type": "tuple", + "internalType": "struct IPlugin.TargetConfig", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "operation", + "type": "uint8", + "internalType": "enum IPlugin.Operation" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "strategies", + "inputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchStrategy" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "strategyCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "_interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "updateFailsafeStrategyMap", + "inputs": [ + { + "name": "_failsafeStrategyMap", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateSettings", + "inputs": [ + { + "name": "_strategies", + "type": "address[]", + "internalType": "contract IDispatchStrategy[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "DispatchHandled", + "inputs": [ + { + "name": "strategyIndex", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "strategy", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "executionId", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "actions", + "type": "tuple[]", + "indexed": false, + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "DispatcherPluginInitialized", + "inputs": [ + { + "name": "pluginId", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "DispatcherSettingsUpdated", + "inputs": [ + { + "name": "strategies", + "type": "address[]", + "indexed": false, + "internalType": "contract IDispatchStrategy[]" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "FailsafeStrategyMapUpdated", + "inputs": [ + { + "name": "failsafeStrategyMap", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MetadataSet", + "inputs": [ + { + "name": "metadata", + "type": "bytes", + "indexed": false, + "internalType": "bytes" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "StrategyFailed", + "inputs": [ + { + "name": "strategyIndex", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "strategy", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "reason", + "type": "bytes", + "indexed": false, + "internalType": "bytes" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TargetSet", + "inputs": [ + { + "name": "newTargetConfig", + "type": "tuple", + "indexed": false, + "internalType": "struct IPlugin.TargetConfig", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "operation", + "type": "uint8", + "internalType": "enum IPlugin.Operation" + } + ] + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "DaoUnauthorized", + "inputs": [ + { + "name": "dao", + "type": "address", + "internalType": "address" + }, + { + "name": "where", + "type": "address", + "internalType": "address" + }, + { + "name": "who", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "DelegateCallFailed", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidFailsafeBitmap", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidTargetConfig", + "inputs": [ + { + "name": "targetConfig", + "type": "tuple", + "internalType": "struct IPlugin.TargetConfig", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "operation", + "type": "uint8", + "internalType": "enum IPlugin.Operation" + } + ] + } + ] + }, + { + "type": "error", + "name": "NoStrategies", + "inputs": [] + }, + { + "type": "error", + "name": "OnlySelf", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/EpochProvider.ts b/src/shared/lidoPreview/abi/generated/EpochProvider.ts new file mode 100644 index 0000000000..82234e6bbd --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/EpochProvider.ts @@ -0,0 +1,144 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/EpochProvider.sol/EpochProvider.json + +export const epochProviderAbi = [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "epochLength", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getEpoch", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_vault", + "type": "address", + "internalType": "address" + }, + { + "name": "_epochLength", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setEpochLength", + "inputs": [ + { + "name": "_newEpochLength", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "vault", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "EpochLengthUpdated", + "inputs": [ + { + "name": "newEpochLength", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "EpochProviderInitialized", + "inputs": [ + { + "name": "vault", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "epochLength", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "InvalidEpochLength", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidVault", + "inputs": [] + }, + { + "type": "error", + "name": "NotVault", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/EpochTransferDispatchStrategy.ts b/src/shared/lidoPreview/abi/generated/EpochTransferDispatchStrategy.ts new file mode 100644 index 0000000000..81d0612458 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/EpochTransferDispatchStrategy.ts @@ -0,0 +1,336 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/EpochTransferDispatchStrategy.sol/EpochTransferDispatchStrategy.json + +export const epochTransferDispatchStrategyAbi = [ + { + "type": "function", + "name": "MANAGER_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PREPARE_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchBudget" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "dao", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDAO" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "epochProvider", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IEpochProvider" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_dao", + "type": "address", + "internalType": "contract IDAO" + }, + { + "name": "_budget", + "type": "address", + "internalType": "contract IDispatchBudget" + }, + { + "name": "_splitter", + "type": "address", + "internalType": "contract IDispatchSplitter" + }, + { + "name": "_epochProvider", + "type": "address", + "internalType": "contract IEpochProvider" + }, + { + "name": "_useSafeTransfer", + "type": "bool", + "internalType": "bool" + }, + { + "name": "_transferHelper", + "type": "address", + "internalType": "contract SafeERC20TransferHelper" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isStreamingBudget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastDispatchedEpoch", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "prepareActions", + "inputs": [], + "outputs": [ + { + "name": "_actions", + "type": "tuple[]", + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPaused", + "inputs": [ + { + "name": "_paused", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "splitter", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchSplitter" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "strategyId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "transferHelper", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract SafeERC20TransferHelper" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "useSafeTransfer", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "DaoUnauthorized", + "inputs": [ + { + "name": "dao", + "type": "address", + "internalType": "address" + }, + { + "name": "where", + "type": "address", + "internalType": "address" + }, + { + "name": "who", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "NoBudget", + "inputs": [] + }, + { + "type": "error", + "name": "NoEpochProvider", + "inputs": [] + }, + { + "type": "error", + "name": "NoHelper", + "inputs": [] + }, + { + "type": "error", + "name": "NoSplitter", + "inputs": [] + }, + { + "type": "error", + "name": "StrategyPaused", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/EqualSplitter.ts b/src/shared/lidoPreview/abi/generated/EqualSplitter.ts new file mode 100644 index 0000000000..b4bed2f0bc --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/EqualSplitter.ts @@ -0,0 +1,199 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/EqualSplitter.sol/EqualSplitter.json + +export const equalSplitterAbi = [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "allocation", + "inputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "_totalBudget", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "allocations", + "inputs": [ + { + "name": "_totalBudget", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "recipients_", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "_allocations", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_recipientList", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "recipientCount", + "inputs": [ + { + "name": "_totalBudget", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "recipients", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "recipients", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "splitterId", + "inputs": [], + "outputs": [ + { + "name": "id", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SplitterSettingsUpdated", + "inputs": [ + { + "name": "recipientList", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "DuplicateRecipient", + "inputs": [] + }, + { + "type": "error", + "name": "NoRecipients", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/FullBudget.ts b/src/shared/lidoPreview/abi/generated/FullBudget.ts new file mode 100644 index 0000000000..a8711ac5f7 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/FullBudget.ts @@ -0,0 +1,121 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/FullBudget.sol/FullBudget.json + +export const fullBudgetAbi = [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budgetId", + "inputs": [], + "outputs": [ + { + "name": "id", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_vault", + "type": "address", + "internalType": "address" + }, + { + "name": "_vaultToken", + "type": "address", + "internalType": "contract IERC20" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "token", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "vault", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "BudgetSettingsUpdated", + "inputs": [ + { + "name": "vault", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "vaultToken", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "InvalidVault", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/GatedCowSwapDispatchStrategy.ts b/src/shared/lidoPreview/abi/generated/GatedCowSwapDispatchStrategy.ts new file mode 100644 index 0000000000..3902216569 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/GatedCowSwapDispatchStrategy.ts @@ -0,0 +1,681 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/GatedCowSwapDispatchStrategy.sol/GatedCowSwapDispatchStrategy.json + +export const gatedCowSwapDispatchStrategyAbi = [ + { + "type": "function", + "name": "DEADLINE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "DEFAULT_APP_DATA", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MANAGER_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PREPARE_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchBudget" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "cowSwapRelayer", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "cowSwapSettlement", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IGPv2Settlement" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "dao", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDAO" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "epochProvider", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IEpochProvider" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "gate", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract PriceFloorGate" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDAO" + }, + { + "name": "", + "type": "address", + "internalType": "contract IDispatchBudget" + }, + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "", + "type": "address", + "internalType": "contract IGPv2Settlement" + }, + { + "name": "", + "type": "address", + "internalType": "contract IPriceOracle" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initializeGated", + "inputs": [ + { + "name": "_dao", + "type": "address", + "internalType": "contract IDAO" + }, + { + "name": "_budget", + "type": "address", + "internalType": "contract IDispatchBudget" + }, + { + "name": "_targetToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "_cowSwapSettlement", + "type": "address", + "internalType": "contract IGPv2Settlement" + }, + { + "name": "_priceOracle", + "type": "address", + "internalType": "contract IPriceOracle" + }, + { + "name": "_maxSlippageBps", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_maxStaleness", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_useSafeApproval", + "type": "bool", + "internalType": "bool" + }, + { + "name": "_gate", + "type": "address", + "internalType": "contract PriceFloorGate" + }, + { + "name": "_epochProvider", + "type": "address", + "internalType": "contract IEpochProvider" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isStreamingBudget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastEpoch", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "maxSlippageBps", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "maxStaleness", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "prepareActions", + "inputs": [], + "outputs": [ + { + "name": "_actions", + "type": "tuple[]", + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "priceOracle", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IPriceOracle" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setPaused", + "inputs": [ + { + "name": "_paused", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "strategyId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "targetToken", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "updateSettings", + "inputs": [ + { + "name": "_targetToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "_cowSwapSettlement", + "type": "address", + "internalType": "contract IGPv2Settlement" + }, + { + "name": "_priceOracle", + "type": "address", + "internalType": "contract IPriceOracle" + }, + { + "name": "_maxSlippageBps", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_maxStaleness", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_useSafeApproval", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "useSafeApproval", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "CowSwapOrderPosted", + "inputs": [ + { + "name": "orderUid", + "type": "bytes", + "indexed": false, + "internalType": "bytes" + }, + { + "name": "sellToken", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "buyToken", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "sellAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "buyAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "validTo", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + }, + { + "name": "appData", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "CowSwapSettingsUpdated", + "inputs": [ + { + "name": "targetToken", + "type": "address", + "indexed": false, + "internalType": "contract IERC20" + }, + { + "name": "cowSwapSettlement", + "type": "address", + "indexed": false, + "internalType": "contract IGPv2Settlement" + }, + { + "name": "cowSwapRelayer", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "priceOracle", + "type": "address", + "indexed": false, + "internalType": "contract IPriceOracle" + }, + { + "name": "maxSlippageBps", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "maxStaleness", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "useSafeApproval", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "GatedCowSwapInitialized", + "inputs": [ + { + "name": "gate", + "type": "address", + "indexed": false, + "internalType": "contract PriceFloorGate" + }, + { + "name": "epochProvider", + "type": "address", + "indexed": false, + "internalType": "contract IEpochProvider" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "DaoUnauthorized", + "inputs": [ + { + "name": "dao", + "type": "address", + "internalType": "address" + }, + { + "name": "where", + "type": "address", + "internalType": "address" + }, + { + "name": "who", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidCowSwapSettlement", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidEpochProvider", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidGate", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidSlippageBps", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidToken", + "inputs": [] + }, + { + "type": "error", + "name": "NoBudget", + "inputs": [] + }, + { + "type": "error", + "name": "NoPriceOracle", + "inputs": [] + }, + { + "type": "error", + "name": "OraclePriceUnavailable", + "inputs": [] + }, + { + "type": "error", + "name": "StrategyPaused", + "inputs": [] + }, + { + "type": "error", + "name": "UseGatedInitialize", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/IDispatchBudget.ts b/src/shared/lidoPreview/abi/generated/IDispatchBudget.ts new file mode 100644 index 0000000000..d00b9a3207 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/IDispatchBudget.ts @@ -0,0 +1,48 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/IDispatchBudget.sol/IDispatchBudget.json + +export const iDispatchBudgetAbi = [ + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budgetId", + "inputs": [], + "outputs": [ + { + "name": "id", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "token", + "inputs": [], + "outputs": [ + { + "name": "token", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/IDispatchSplitter.ts b/src/shared/lidoPreview/abi/generated/IDispatchSplitter.ts new file mode 100644 index 0000000000..5f366079f9 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/IDispatchSplitter.ts @@ -0,0 +1,65 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/IDispatchSplitter.sol/IDispatchSplitter.json + +export const iDispatchSplitterAbi = [ + { + "type": "function", + "name": "allocations", + "inputs": [ + { + "name": "totalBudget", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "recipients", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "allocations", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "recipientCount", + "inputs": [ + { + "name": "totalBudget", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "splitterId", + "inputs": [], + "outputs": [ + { + "name": "id", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/IDispatchStrategy.ts b/src/shared/lidoPreview/abi/generated/IDispatchStrategy.ts new file mode 100644 index 0000000000..333da6405e --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/IDispatchStrategy.ts @@ -0,0 +1,91 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/IDispatchStrategy.sol/IDispatchStrategy.json + +export const iDispatchStrategyAbi = [ + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchBudget" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "prepareActions", + "inputs": [], + "outputs": [ + { + "name": "actions", + "type": "tuple[]", + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPaused", + "inputs": [ + { + "name": "paused", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "strategyId", + "inputs": [], + "outputs": [ + { + "name": "id", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "nonpayable" + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/IDispatcherPlugin.ts b/src/shared/lidoPreview/abi/generated/IDispatcherPlugin.ts new file mode 100644 index 0000000000..167041a3ba --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/IDispatcherPlugin.ts @@ -0,0 +1,173 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/IDispatcherPlugin.sol/IDispatcherPlugin.json + +export const iDispatcherPluginAbi = [ + { + "type": "function", + "name": "dispatch", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "pluginId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "strategies", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchStrategy" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "strategyCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "DispatchHandled", + "inputs": [ + { + "name": "strategyIndex", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "strategy", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "executionId", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "actions", + "type": "tuple[]", + "indexed": false, + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "DispatcherPluginInitialized", + "inputs": [ + { + "name": "pluginId", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "DispatcherSettingsUpdated", + "inputs": [ + { + "name": "strategies", + "type": "address[]", + "indexed": false, + "internalType": "contract IDispatchStrategy[]" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "FailsafeStrategyMapUpdated", + "inputs": [ + { + "name": "failsafeStrategyMap", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "StrategyFailed", + "inputs": [ + { + "name": "strategyIndex", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "strategy", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "reason", + "type": "bytes", + "indexed": false, + "internalType": "bytes" + } + ], + "anonymous": false + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/IEpochProvider.ts b/src/shared/lidoPreview/abi/generated/IEpochProvider.ts new file mode 100644 index 0000000000..71539f3557 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/IEpochProvider.ts @@ -0,0 +1,22 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/IEpochProvider.sol/IEpochProvider.json + +export const iEpochProviderAbi = [ + { + "type": "function", + "name": "getEpoch", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/MockCowSwapSettlement.ts b/src/shared/lidoPreview/abi/generated/MockCowSwapSettlement.ts new file mode 100644 index 0000000000..78ad2642fe --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/MockCowSwapSettlement.ts @@ -0,0 +1,112 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/MockCowSwapSettlement.sol/MockCowSwapSettlement.json + +export const mockCowSwapSettlementAbi = [ + { + "type": "function", + "name": "domainSeparator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastOrderUid", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "ordersPlaced", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setPreSignature", + "inputs": [ + { + "name": "orderUid", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "signed", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "simulateFill", + "inputs": [ + { + "name": "sellToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "buyToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "trader", + "type": "address", + "internalType": "address" + }, + { + "name": "sellAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "buyAmount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "vaultRelayer", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/MockPriceOracle.ts b/src/shared/lidoPreview/abi/generated/MockPriceOracle.ts new file mode 100644 index 0000000000..60d4a94a4c --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/MockPriceOracle.ts @@ -0,0 +1,121 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/MockPriceOracle.sol/MockPriceOracle.json + +export const mockPriceOracleAbi = [ + { + "type": "function", + "name": "getPrice", + "inputs": [ + { + "name": "_sell", + "type": "address", + "internalType": "address" + }, + { + "name": "_buy", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "oracleId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "refresh", + "inputs": [ + { + "name": "_sell", + "type": "address", + "internalType": "address" + }, + { + "name": "_buy", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPrice", + "inputs": [ + { + "name": "_sell", + "type": "address", + "internalType": "address" + }, + { + "name": "_buy", + "type": "address", + "internalType": "address" + }, + { + "name": "_price", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_updatedAt", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supportsPair", + "inputs": [ + { + "name": "_sell", + "type": "address", + "internalType": "address" + }, + { + "name": "_buy", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/PriceFloorGate.ts b/src/shared/lidoPreview/abi/generated/PriceFloorGate.ts new file mode 100644 index 0000000000..1ca4e2fbfd --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/PriceFloorGate.ts @@ -0,0 +1,300 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/PriceFloorGate.sol/PriceFloorGate.json + +export const priceFloorGateAbi = [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_vault", + "type": "address", + "internalType": "address" + }, + { + "name": "_oracle", + "type": "address", + "internalType": "contract IPriceOracle" + }, + { + "name": "_tokenA", + "type": "address", + "internalType": "address" + }, + { + "name": "_tokenB", + "type": "address", + "internalType": "address" + }, + { + "name": "_threshold", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_maxStaleness", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "maxStaleness", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "oracle", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IPriceOracle" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "passes", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setMaxStaleness", + "inputs": [ + { + "name": "_newMaxStaleness", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setThreshold", + "inputs": [ + { + "name": "_newThreshold", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "threshold", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "tokenA", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "tokenB", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "vault", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "GateSettingsUpdated", + "inputs": [ + { + "name": "vault", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "oracle", + "type": "address", + "indexed": false, + "internalType": "contract IPriceOracle" + }, + { + "name": "tokenA", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "tokenB", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "threshold", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "maxStaleness", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MaxStalenessUpdated", + "inputs": [ + { + "name": "newMaxStaleness", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ThresholdUpdated", + "inputs": [ + { + "name": "newThreshold", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "InvalidOracle", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidPair", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidThreshold", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidVault", + "inputs": [] + }, + { + "type": "error", + "name": "NotVault", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedPair", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/RatioSplitter.ts b/src/shared/lidoPreview/abi/generated/RatioSplitter.ts new file mode 100644 index 0000000000..64be1e669f --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/RatioSplitter.ts @@ -0,0 +1,239 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/RatioSplitter.sol/RatioSplitter.json + +export const ratioSplitterAbi = [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "allocation", + "inputs": [ + { + "name": "recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "_totalBudget", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "allocations", + "inputs": [ + { + "name": "_totalBudget", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "recipients_", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "_allocations", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_recipientList", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "_ratioList", + "type": "uint32[]", + "internalType": "uint32[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "ratios", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "recipientCount", + "inputs": [ + { + "name": "_totalBudget", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "recipients", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address[]", + "internalType": "address[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "recipients", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "splitterId", + "inputs": [], + "outputs": [ + { + "name": "id", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SplitterSettingsUpdated", + "inputs": [ + { + "name": "recipientList", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + }, + { + "name": "ratioList", + "type": "uint32[]", + "indexed": false, + "internalType": "uint32[]" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "ArrayLengthMismatch", + "inputs": [] + }, + { + "type": "error", + "name": "DuplicateRecipient", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidRatios", + "inputs": [] + }, + { + "type": "error", + "name": "NoRecipients", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/RequiredBudget.ts b/src/shared/lidoPreview/abi/generated/RequiredBudget.ts new file mode 100644 index 0000000000..53d68fbfd8 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/RequiredBudget.ts @@ -0,0 +1,150 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/RequiredBudget.sol/RequiredBudget.json + +export const requiredBudgetAbi = [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budgetId", + "inputs": [], + "outputs": [ + { + "name": "id", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_vault", + "type": "address", + "internalType": "address" + }, + { + "name": "_vaultToken", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "_requiredBalance", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "requiredBalance", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "token", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "vault", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "BudgetSettingsUpdated", + "inputs": [ + { + "name": "vault", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "vaultToken", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "requiredBalance", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "InsufficientBalance", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidVault", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/SoloSplitter.ts b/src/shared/lidoPreview/abi/generated/SoloSplitter.ts new file mode 100644 index 0000000000..b2cf713f0e --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/SoloSplitter.ts @@ -0,0 +1,162 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/SoloSplitter.sol/SoloSplitter.json + +export const soloSplitterAbi = [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "allocation", + "inputs": [ + { + "name": "_recipient", + "type": "address", + "internalType": "address" + }, + { + "name": "_totalBudget", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "allocations", + "inputs": [ + { + "name": "_totalBudget", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "_recipients", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "_amounts", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "recipient", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "recipientCount", + "inputs": [ + { + "name": "_totalBudget", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "splitterId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "NoRecipient", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/StreamUntilBudget.ts b/src/shared/lidoPreview/abi/generated/StreamUntilBudget.ts new file mode 100644 index 0000000000..13248d8903 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/StreamUntilBudget.ts @@ -0,0 +1,296 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/StreamUntilBudget.sol/StreamUntilBudget.json + +export const streamUntilBudgetAbi = [ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budgetId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "epochProvider", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IEpochProvider" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "floorEpochs", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_vault", + "type": "address", + "internalType": "address" + }, + { + "name": "vaultToken_", + "type": "address", + "internalType": "contract IERC20" + }, + { + "name": "_epochProvider", + "type": "address", + "internalType": "contract IEpochProvider" + }, + { + "name": "_targetEpoch", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "_floorEpochs", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setEpochProvider", + "inputs": [ + { + "name": "_newEpochProvider", + "type": "address", + "internalType": "contract IEpochProvider" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setFloorEpochs", + "inputs": [ + { + "name": "_newFloorEpochs", + "type": "uint32", + "internalType": "uint32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setTargetEpoch", + "inputs": [ + { + "name": "_newTargetEpoch", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "targetEpoch", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "token", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "vault", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "BudgetInitialized", + "inputs": [ + { + "name": "vault", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "vaultToken", + "type": "address", + "indexed": false, + "internalType": "contract IERC20" + }, + { + "name": "epochProvider", + "type": "address", + "indexed": false, + "internalType": "contract IEpochProvider" + }, + { + "name": "targetEpoch", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + }, + { + "name": "floorEpochs", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "EpochProviderUpdated", + "inputs": [ + { + "name": "newEpochProvider", + "type": "address", + "indexed": false, + "internalType": "contract IEpochProvider" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "FloorEpochsUpdated", + "inputs": [ + { + "name": "newFloorEpochs", + "type": "uint32", + "indexed": false, + "internalType": "uint32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "TargetEpochUpdated", + "inputs": [ + { + "name": "newTargetEpoch", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "InvalidEpochProvider", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidFloorEpochs", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidTargetEpoch", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidToken", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidVault", + "inputs": [] + }, + { + "type": "error", + "name": "NotVault", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/TransferDispatchStrategy.ts b/src/shared/lidoPreview/abi/generated/TransferDispatchStrategy.ts new file mode 100644 index 0000000000..d3ca210d8b --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/TransferDispatchStrategy.ts @@ -0,0 +1,300 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/TransferDispatchStrategy.sol/TransferDispatchStrategy.json + +export const transferDispatchStrategyAbi = [ + { + "type": "function", + "name": "MANAGER_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PREPARE_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchBudget" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "dao", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDAO" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_dao", + "type": "address", + "internalType": "contract IDAO" + }, + { + "name": "_budget", + "type": "address", + "internalType": "contract IDispatchBudget" + }, + { + "name": "_splitter", + "type": "address", + "internalType": "contract IDispatchSplitter" + }, + { + "name": "_useSafeTransfer", + "type": "bool", + "internalType": "bool" + }, + { + "name": "_transferHelper", + "type": "address", + "internalType": "contract SafeERC20TransferHelper" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isStreamingBudget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "prepareActions", + "inputs": [], + "outputs": [ + { + "name": "_actions", + "type": "tuple[]", + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPaused", + "inputs": [ + { + "name": "_paused", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "splitter", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchSplitter" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "strategyId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "transferHelper", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract SafeERC20TransferHelper" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "useSafeTransfer", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "DaoUnauthorized", + "inputs": [ + { + "name": "dao", + "type": "address", + "internalType": "address" + }, + { + "name": "where", + "type": "address", + "internalType": "address" + }, + { + "name": "who", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "NoBudget", + "inputs": [] + }, + { + "type": "error", + "name": "NoHelper", + "inputs": [] + }, + { + "type": "error", + "name": "NoSplitter", + "inputs": [] + }, + { + "type": "error", + "name": "StrategyPaused", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/UniV2LiquidityDispatchStrategy.ts b/src/shared/lidoPreview/abi/generated/UniV2LiquidityDispatchStrategy.ts new file mode 100644 index 0000000000..3802a7b05b --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/UniV2LiquidityDispatchStrategy.ts @@ -0,0 +1,410 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/UniV2LiquidityDispatchStrategy.sol/UniV2LiquidityDispatchStrategy.json + +export const uniV2LiquidityDispatchStrategyAbi = [ + { + "type": "function", + "name": "MANAGER_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PREPARE_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchBudget" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budgetB", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchBudget" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "dao", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDAO" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "epochProvider", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IEpochProvider" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_dao", + "type": "address", + "internalType": "contract IDAO" + }, + { + "name": "_budgetA", + "type": "address", + "internalType": "contract IDispatchBudget" + }, + { + "name": "_budgetB", + "type": "address", + "internalType": "contract IDispatchBudget" + }, + { + "name": "_router", + "type": "address", + "internalType": "contract IUniswapV2Router02" + }, + { + "name": "_oracle", + "type": "address", + "internalType": "contract IPriceOracle" + }, + { + "name": "_epochProvider", + "type": "address", + "internalType": "contract IEpochProvider" + }, + { + "name": "_maxSlippageBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "_maxStaleness", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_lpRecipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isStreamingBudget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastEpoch", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lpRecipient", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "maxSlippageBps", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint16", + "internalType": "uint16" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "maxStaleness", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "oracle", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IPriceOracle" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "prepareActions", + "inputs": [], + "outputs": [ + { + "name": "_actions", + "type": "tuple[]", + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "router", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IUniswapV2Router02" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setPaused", + "inputs": [ + { + "name": "_paused", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "strategyId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "DaoUnauthorized", + "inputs": [ + { + "name": "dao", + "type": "address", + "internalType": "address" + }, + { + "name": "where", + "type": "address", + "internalType": "address" + }, + { + "name": "who", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidEpochProvider", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidLpRecipient", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidOracle", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidRouter", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidSlippage", + "inputs": [] + }, + { + "type": "error", + "name": "NoBudget", + "inputs": [] + }, + { + "type": "error", + "name": "SameBudgetToken", + "inputs": [] + }, + { + "type": "error", + "name": "StrategyPaused", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedOraclePair", + "inputs": [] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/WrapDispatchStrategy.ts b/src/shared/lidoPreview/abi/generated/WrapDispatchStrategy.ts new file mode 100644 index 0000000000..022c3b62d2 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/WrapDispatchStrategy.ts @@ -0,0 +1,270 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. +// Source: out/WrapDispatchStrategy.sol/WrapDispatchStrategy.json + +export const wrapDispatchStrategyAbi = [ + { + "type": "function", + "name": "MANAGER_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PREPARE_PERMISSION_ID", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "budget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDispatchBudget" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "dao", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDAO" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [ + { + "name": "_dao", + "type": "address", + "internalType": "contract IDAO" + }, + { + "name": "_budget", + "type": "address", + "internalType": "contract IDispatchBudget" + }, + { + "name": "_wstETH", + "type": "address", + "internalType": "contract IWstETH" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isStreamingBudget", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "paused", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "prepareActions", + "inputs": [], + "outputs": [ + { + "name": "_actions", + "type": "tuple[]", + "internalType": "struct Action[]", + "components": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setPaused", + "inputs": [ + { + "name": "_paused", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "strategyId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "wstETH", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IWstETH" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "account", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "DaoUnauthorized", + "inputs": [ + { + "name": "dao", + "type": "address", + "internalType": "address" + }, + { + "name": "where", + "type": "address", + "internalType": "address" + }, + { + "name": "who", + "type": "address", + "internalType": "address" + }, + { + "name": "permissionId", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "NoBudget", + "inputs": [] + }, + { + "type": "error", + "name": "StrategyPaused", + "inputs": [] + }, + { + "type": "error", + "name": "WrongBudgetToken", + "inputs": [ + { + "name": "expected", + "type": "address", + "internalType": "address" + }, + { + "name": "actual", + "type": "address", + "internalType": "address" + } + ] + } +] as const; diff --git a/src/shared/lidoPreview/abi/generated/index.ts b/src/shared/lidoPreview/abi/generated/index.ts new file mode 100644 index 0000000000..2921f88978 --- /dev/null +++ b/src/shared/lidoPreview/abi/generated/index.ts @@ -0,0 +1,28 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generated by scripts/gen-abis.ts — do not edit by hand. + +export * from "./DispatcherPlugin"; +export * from "./FullBudget"; +export * from "./WrapDispatchStrategy"; +export * from "./UniV2LiquidityDispatchStrategy"; +export * from "./GatedCowSwapDispatchStrategy"; +export * from "./StreamUntilBudget"; +export * from "./EpochProvider"; +export * from "./PriceFloorGate"; +export * from "./MockPriceOracle"; +export * from "./MockCowSwapSettlement"; +export * from "./IDispatcherPlugin"; +export * from "./IDispatchStrategy"; +export * from "./IDispatchBudget"; +export * from "./IEpochProvider"; +export * from "./TransferDispatchStrategy"; +export * from "./BurnDispatchStrategy"; +export * from "./EpochTransferDispatchStrategy"; +export * from "./RequiredBudget"; +export * from "./SoloSplitter"; +export * from "./EqualSplitter"; +export * from "./RatioSplitter"; +export * from "./IDispatchSplitter"; diff --git a/src/shared/lidoPreview/components/budgets/fullBudget.ts b/src/shared/lidoPreview/components/budgets/fullBudget.ts new file mode 100644 index 0000000000..d6c4352faf --- /dev/null +++ b/src/shared/lidoPreview/components/budgets/fullBudget.ts @@ -0,0 +1,42 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import { fullBudgetAbi } from '../../abi/generated/FullBudget'; +import { fetchTokenInfo } from '../../introspect/token'; +import { registerBudget } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { FullBudgetNode } from '../../types/topology'; + +const ID = 'org.aragon.budget.full'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [vault, tokenAddress] = await Promise.all([ + ctx.client.readContract({ + address, + abi: fullBudgetAbi, + functionName: 'vault', + }), + ctx.client.readContract({ + address, + abi: fullBudgetAbi, + functionName: 'token', + }), + ]); + + const token = await fetchTokenInfo(ctx.client, tokenAddress); + + return { + kind: 'budget.full', + address, + budgetId: ID, + token, + vault, + }; +} + +registerBudget({ id: ID, kind: 'budget.full', inspect }); diff --git a/src/shared/lidoPreview/components/budgets/requiredBudget.ts b/src/shared/lidoPreview/components/budgets/requiredBudget.ts new file mode 100644 index 0000000000..2659ec45c3 --- /dev/null +++ b/src/shared/lidoPreview/components/budgets/requiredBudget.ts @@ -0,0 +1,48 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import { requiredBudgetAbi } from '../../abi/generated/RequiredBudget'; +import { fetchTokenInfo } from '../../introspect/token'; +import { registerBudget } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { RequiredBudgetNode } from '../../types/topology'; + +const ID = 'org.aragon.budget.required'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [vault, tokenAddress, requiredAmount] = await Promise.all([ + ctx.client.readContract({ + address, + abi: requiredBudgetAbi, + functionName: 'vault', + }), + ctx.client.readContract({ + address, + abi: requiredBudgetAbi, + functionName: 'token', + }), + ctx.client.readContract({ + address, + abi: requiredBudgetAbi, + functionName: 'requiredBalance', + }), + ]); + + const token = await fetchTokenInfo(ctx.client, tokenAddress); + + return { + kind: 'budget.required', + address, + budgetId: ID, + token, + vault, + requiredAmount, + }; +} + +registerBudget({ id: ID, kind: 'budget.required', inspect }); diff --git a/src/shared/lidoPreview/components/budgets/streamUntilBudget.ts b/src/shared/lidoPreview/components/budgets/streamUntilBudget.ts new file mode 100644 index 0000000000..c996c274c0 --- /dev/null +++ b/src/shared/lidoPreview/components/budgets/streamUntilBudget.ts @@ -0,0 +1,76 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Lido StreamUntilBudget inspector — reads the operator-paced stream state. +// +// `budget() = balance / max(targetEpoch − currentEpoch, floorEpochs)`. +// The inspector surfaces the configuration; the simulator's budget reader +// derives the per-tick number from `ChainState` + `currentEpoch`. + +import type { Address } from 'viem'; +import { streamUntilBudgetAbi } from '../../abi/generated/StreamUntilBudget'; +import { dispatchEpochProvider } from '../../introspect/dispatch'; +import { fetchTokenInfo } from '../../introspect/token'; +import { registerBudget } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { LidoStreamUntilBudgetNode } from '../../types/topology'; + +const ID = 'com.aragon.lido.budget.stream-until'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [ + vault, + tokenAddress, + epochProviderAddress, + targetEpoch, + floorEpochs, + ] = await Promise.all([ + ctx.client.readContract({ + address, + abi: streamUntilBudgetAbi, + functionName: 'vault', + }), + ctx.client.readContract({ + address, + abi: streamUntilBudgetAbi, + functionName: 'token', + }), + ctx.client.readContract({ + address, + abi: streamUntilBudgetAbi, + functionName: 'epochProvider', + }), + ctx.client.readContract({ + address, + abi: streamUntilBudgetAbi, + functionName: 'targetEpoch', + }), + ctx.client.readContract({ + address, + abi: streamUntilBudgetAbi, + functionName: 'floorEpochs', + }), + ]); + + const [token, epochProvider] = await Promise.all([ + fetchTokenInfo(ctx.client, tokenAddress), + dispatchEpochProvider(epochProviderAddress, ctx), + ]); + + return { + kind: 'budget.lido.stream-until', + address, + budgetId: ID, + token, + vault, + epochProvider, + targetEpoch: BigInt(targetEpoch), + floorEpochs: Number(floorEpochs), + }; +} + +registerBudget({ id: ID, kind: 'budget.lido.stream-until', inspect }); diff --git a/src/shared/lidoPreview/components/index.ts b/src/shared/lidoPreview/components/index.ts new file mode 100644 index 0000000000..38a2a775ee --- /dev/null +++ b/src/shared/lidoPreview/components/index.ts @@ -0,0 +1,33 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Side-effect barrel: importing this file registers every built-in +// component with the registry. Adding a new component = one import. +// +// The CR vanilla components are kept as catch-alls — they're harmless when +// the active deployment doesn't use them (the registry only looks them up +// when an inspected component reports their `*Id`). Lido-specific +// components live alongside. + +// Splitters (CR — unused by the LMM but kept so mixed deployments don't +// fall through to unknown unnecessarily) +import './splitters/soloSplitter'; +import './splitters/equalSplitter'; +import './splitters/ratioSplitter'; + +// Budgets — CR (FullBudget / RequiredBudget) + Lido (StreamUntilBudget). +import './budgets/fullBudget'; +import './budgets/requiredBudget'; +import './budgets/streamUntilBudget'; + +// Strategies — CR (Transfer / Burn / EpochTransfer) + Lido (Wrap, UniV2, GatedCowSwap). +import './strategies/burnDispatch'; +import './strategies/transferDispatch'; +import './strategies/epochTransferDispatch'; +import './strategies/lidoWrap'; +import './strategies/lidoUniV2Liquidity'; +import './strategies/lidoGatedCowSwap'; + +// Plugins +import './plugins/dispatcherPlugin'; diff --git a/src/shared/lidoPreview/components/plugins/dispatcherPlugin.ts b/src/shared/lidoPreview/components/plugins/dispatcherPlugin.ts new file mode 100644 index 0000000000..472067ad43 --- /dev/null +++ b/src/shared/lidoPreview/components/plugins/dispatcherPlugin.ts @@ -0,0 +1,64 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import { dispatcherPluginAbi } from '../../abi/generated/DispatcherPlugin'; +import { dispatchStrategy } from '../../introspect/dispatch'; +import { registerPlugin } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { DispatcherPluginNode } from '../../types/topology'; + +const ID = 'org.aragon.plugin.dispatch'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [dao, strategyCount, failsafeStrategyMap] = await Promise.all([ + ctx.client.readContract({ + address, + abi: dispatcherPluginAbi, + functionName: 'dao', + }), + ctx.client.readContract({ + address, + abi: dispatcherPluginAbi, + functionName: 'strategyCount', + }), + ctx.client.readContract({ + address, + abi: dispatcherPluginAbi, + functionName: 'failsafeStrategyMap', + }), + ]); + + // Read every strategy address by index, then recurse in parallel. + const indices = Array.from({ length: Number(strategyCount) }, (_, i) => + BigInt(i), + ); + const strategyAddresses = await Promise.all( + indices.map((i) => + ctx.client.readContract({ + address, + abi: dispatcherPluginAbi, + functionName: 'strategies', + args: [i], + }), + ), + ); + const strategies = await Promise.all( + strategyAddresses.map((addr) => dispatchStrategy(addr, ctx)), + ); + + return { + kind: 'plugin.dispatch', + address, + pluginId: ID, + dao, + strategies, + failsafeStrategyMap, + }; +} + +registerPlugin({ id: ID, kind: 'plugin.dispatch', inspect }); diff --git a/src/shared/lidoPreview/components/splitters/equalSplitter.ts b/src/shared/lidoPreview/components/splitters/equalSplitter.ts new file mode 100644 index 0000000000..a8c52bee0c --- /dev/null +++ b/src/shared/lidoPreview/components/splitters/equalSplitter.ts @@ -0,0 +1,33 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import { equalSplitterAbi } from '../../abi/generated/EqualSplitter'; +import { registerSplitter } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { EqualSplitterNode } from '../../types/topology'; + +const ID = 'org.aragon.splitter.equal'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + // EqualSplitter overloads `recipients()` — the no-arg variant returns the + // full array; the indexed variant returns one entry. We use the array form. + const recipients = await ctx.client.readContract({ + address, + abi: equalSplitterAbi, + functionName: 'recipients', + args: [], + }); + return { + kind: 'splitter.equal', + address, + splitterId: ID, + recipients: [...recipients], + }; +} + +registerSplitter({ id: ID, kind: 'splitter.equal', inspect }); diff --git a/src/shared/lidoPreview/components/splitters/ratioSplitter.ts b/src/shared/lidoPreview/components/splitters/ratioSplitter.ts new file mode 100644 index 0000000000..baeb45c750 --- /dev/null +++ b/src/shared/lidoPreview/components/splitters/ratioSplitter.ts @@ -0,0 +1,49 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import { ratioSplitterAbi } from '../../abi/generated/RatioSplitter'; +import { registerSplitter } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { RatioSplitterNode } from '../../types/topology'; + +const ID = 'org.aragon.splitter.ratio'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const recipients = await ctx.client.readContract({ + address, + abi: ratioSplitterAbi, + functionName: 'recipients', + args: [], + }); + + // `ratios` is a mapping(address => uint32); one call per recipient. + // For splitters with many recipients, batching via multicall would be an + // optimization — tracked in TASKS.md. + const ratios = await Promise.all( + recipients.map((recipient) => + ctx.client.readContract({ + address, + abi: ratioSplitterAbi, + functionName: 'ratios', + args: [recipient], + }), + ), + ); + + return { + kind: 'splitter.ratio', + address, + splitterId: ID, + entries: recipients.map((recipient, i) => ({ + recipient, + ratio: Number(ratios[i]), + })), + }; +} + +registerSplitter({ id: ID, kind: 'splitter.ratio', inspect }); diff --git a/src/shared/lidoPreview/components/splitters/soloSplitter.ts b/src/shared/lidoPreview/components/splitters/soloSplitter.ts new file mode 100644 index 0000000000..fc8f8a70a4 --- /dev/null +++ b/src/shared/lidoPreview/components/splitters/soloSplitter.ts @@ -0,0 +1,30 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import { soloSplitterAbi } from '../../abi/generated/SoloSplitter'; +import { registerSplitter } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { SoloSplitterNode } from '../../types/topology'; + +const ID = 'org.aragon.splitter.solo'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const recipient = await ctx.client.readContract({ + address, + abi: soloSplitterAbi, + functionName: 'recipient', + }); + return { + kind: 'splitter.solo', + address, + splitterId: ID, + recipient, + }; +} + +registerSplitter({ id: ID, kind: 'splitter.solo', inspect }); diff --git a/src/shared/lidoPreview/components/strategies/_priceFloorGate.ts b/src/shared/lidoPreview/components/strategies/_priceFloorGate.ts new file mode 100644 index 0000000000..571111c3e6 --- /dev/null +++ b/src/shared/lidoPreview/components/strategies/_priceFloorGate.ts @@ -0,0 +1,64 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// PriceFloorGate inspector — auxiliary node attached to GatedCowSwap. +// +// Not registered against any *Id() because the gate has no id selector; the +// gated-cowswap inspector wires this in directly. Lives under strategies/ +// because that's the only component that consults it today. + +import type { Address } from 'viem'; +import { priceFloorGateAbi } from '../../abi/generated/PriceFloorGate'; +import type { IntrospectionContext } from '../../registry/types'; +import type { PriceFloorGateNode } from '../../types/topology'; + +export async function inspectPriceFloorGate( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [vault, oracle, tokenA, tokenB, threshold, maxStaleness] = + await Promise.all([ + ctx.client.readContract({ + address, + abi: priceFloorGateAbi, + functionName: 'vault', + }), + ctx.client.readContract({ + address, + abi: priceFloorGateAbi, + functionName: 'oracle', + }), + ctx.client.readContract({ + address, + abi: priceFloorGateAbi, + functionName: 'tokenA', + }), + ctx.client.readContract({ + address, + abi: priceFloorGateAbi, + functionName: 'tokenB', + }), + ctx.client.readContract({ + address, + abi: priceFloorGateAbi, + functionName: 'threshold', + }), + ctx.client.readContract({ + address, + abi: priceFloorGateAbi, + functionName: 'maxStaleness', + }), + ]); + + return { + kind: 'lido.price-floor-gate', + address, + vault, + oracle, + tokenA, + tokenB, + threshold: BigInt(threshold), + maxStaleness: BigInt(maxStaleness), + }; +} diff --git a/src/shared/lidoPreview/components/strategies/burnDispatch.ts b/src/shared/lidoPreview/components/strategies/burnDispatch.ts new file mode 100644 index 0000000000..c59d5a20fe --- /dev/null +++ b/src/shared/lidoPreview/components/strategies/burnDispatch.ts @@ -0,0 +1,42 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import { burnDispatchStrategyAbi } from '../../abi/generated/BurnDispatchStrategy'; +import { dispatchBudget } from '../../introspect/dispatch'; +import { registerStrategy } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { BurnDispatchStrategyNode } from '../../types/topology'; + +const ID = 'org.aragon.strategy.dispatch.burn'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [paused, budgetAddress] = await Promise.all([ + ctx.client.readContract({ + address, + abi: burnDispatchStrategyAbi, + functionName: 'paused', + }), + ctx.client.readContract({ + address, + abi: burnDispatchStrategyAbi, + functionName: 'budget', + }), + ]); + + const budget = await dispatchBudget(budgetAddress, ctx); + + return { + kind: 'strategy.dispatch.burn', + address, + strategyId: ID, + paused, + budget, + }; +} + +registerStrategy({ id: ID, kind: 'strategy.dispatch.burn', inspect }); diff --git a/src/shared/lidoPreview/components/strategies/epochTransferDispatch.ts b/src/shared/lidoPreview/components/strategies/epochTransferDispatch.ts new file mode 100644 index 0000000000..5750a29254 --- /dev/null +++ b/src/shared/lidoPreview/components/strategies/epochTransferDispatch.ts @@ -0,0 +1,85 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import { epochTransferDispatchStrategyAbi } from '../../abi/generated/EpochTransferDispatchStrategy'; +import { + dispatchBudget, + dispatchEpochProvider, + dispatchSplitter, +} from '../../introspect/dispatch'; +import { registerStrategy } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { EpochTransferDispatchStrategyNode } from '../../types/topology'; + +const ID = 'org.aragon.strategy.dispatch.epoch-transfer'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [ + paused, + budgetAddress, + splitterAddress, + epochProviderAddress, + lastDispatchedEpoch, + useSafeTransfer, + ] = await Promise.all([ + ctx.client.readContract({ + address, + abi: epochTransferDispatchStrategyAbi, + functionName: 'paused', + }), + ctx.client.readContract({ + address, + abi: epochTransferDispatchStrategyAbi, + functionName: 'budget', + }), + ctx.client.readContract({ + address, + abi: epochTransferDispatchStrategyAbi, + functionName: 'splitter', + }), + ctx.client.readContract({ + address, + abi: epochTransferDispatchStrategyAbi, + functionName: 'epochProvider', + }), + ctx.client.readContract({ + address, + abi: epochTransferDispatchStrategyAbi, + functionName: 'lastDispatchedEpoch', + }), + ctx.client.readContract({ + address, + abi: epochTransferDispatchStrategyAbi, + functionName: 'useSafeTransfer', + }), + ]); + + const [budget, splitter, epochProvider] = await Promise.all([ + dispatchBudget(budgetAddress, ctx), + dispatchSplitter(splitterAddress, ctx), + dispatchEpochProvider(epochProviderAddress, ctx), + ]); + + return { + kind: 'strategy.dispatch.epoch-transfer', + address, + strategyId: ID, + paused, + budget, + splitter, + epochProvider, + lastDispatchedEpoch, + useSafeTransfer, + }; +} + +registerStrategy({ + id: ID, + kind: 'strategy.dispatch.epoch-transfer', + inspect, +}); diff --git a/src/shared/lidoPreview/components/strategies/lidoGatedCowSwap.ts b/src/shared/lidoPreview/components/strategies/lidoGatedCowSwap.ts new file mode 100644 index 0000000000..34d91d7640 --- /dev/null +++ b/src/shared/lidoPreview/components/strategies/lidoGatedCowSwap.ts @@ -0,0 +1,131 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Lido GatedCowSwapDispatchStrategy inspector — reads CR's CowSwap state +// (target token, settlement, oracle, slippage/staleness, safe-approval flag) +// plus the gate / epoch-provider additions. + +import { type Address, parseAbi } from 'viem'; +import { gatedCowSwapDispatchStrategyAbi } from '../../abi/generated/GatedCowSwapDispatchStrategy'; +import { + dispatchBudget, + dispatchEpochProvider, +} from '../../introspect/dispatch'; +import { fetchTokenInfo } from '../../introspect/token'; +import { registerStrategy } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { LidoGatedCowSwapDispatchStrategyNode } from '../../types/topology'; +import { inspectPriceFloorGate } from './_priceFloorGate'; + +const ID = 'com.aragon.lido.strategy.dispatch.gated-cowswap'; + +const baseAbi = parseAbi([ + 'function paused() view returns (bool)', + 'function budget() view returns (address)', +]); + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [ + paused, + budgetAddress, + targetTokenAddress, + cowSwapSettlement, + priceOracle, + maxSlippageBps, + maxStaleness, + useSafeApproval, + gateAddress, + epochProviderAddress, + lastEpoch, + ] = await Promise.all([ + ctx.client.readContract({ + address, + abi: baseAbi, + functionName: 'paused', + }), + ctx.client.readContract({ + address, + abi: baseAbi, + functionName: 'budget', + }), + ctx.client.readContract({ + address, + abi: gatedCowSwapDispatchStrategyAbi, + functionName: 'targetToken', + }), + ctx.client.readContract({ + address, + abi: gatedCowSwapDispatchStrategyAbi, + functionName: 'cowSwapSettlement', + }), + ctx.client.readContract({ + address, + abi: gatedCowSwapDispatchStrategyAbi, + functionName: 'priceOracle', + }), + ctx.client.readContract({ + address, + abi: gatedCowSwapDispatchStrategyAbi, + functionName: 'maxSlippageBps', + }), + ctx.client.readContract({ + address, + abi: gatedCowSwapDispatchStrategyAbi, + functionName: 'maxStaleness', + }), + ctx.client.readContract({ + address, + abi: gatedCowSwapDispatchStrategyAbi, + functionName: 'useSafeApproval', + }), + ctx.client.readContract({ + address, + abi: gatedCowSwapDispatchStrategyAbi, + functionName: 'gate', + }), + ctx.client.readContract({ + address, + abi: gatedCowSwapDispatchStrategyAbi, + functionName: 'epochProvider', + }), + ctx.client.readContract({ + address, + abi: gatedCowSwapDispatchStrategyAbi, + functionName: 'lastEpoch', + }), + ]); + + const [budget, targetToken, gate, epochProvider] = await Promise.all([ + dispatchBudget(budgetAddress, ctx), + fetchTokenInfo(ctx.client, targetTokenAddress), + inspectPriceFloorGate(gateAddress, ctx), + dispatchEpochProvider(epochProviderAddress, ctx), + ]); + + return { + kind: 'strategy.dispatch.lido.gated-cowswap', + address, + strategyId: ID, + paused, + budget, + targetToken, + cowSwapSettlement, + priceOracle, + maxSlippageBps: BigInt(maxSlippageBps), + maxStaleness: BigInt(maxStaleness), + useSafeApproval, + gate, + epochProvider, + lastEpoch: BigInt(lastEpoch), + }; +} + +registerStrategy({ + id: ID, + kind: 'strategy.dispatch.lido.gated-cowswap', + inspect, +}); diff --git a/src/shared/lidoPreview/components/strategies/lidoUniV2Liquidity.ts b/src/shared/lidoPreview/components/strategies/lidoUniV2Liquidity.ts new file mode 100644 index 0000000000..d1c477a00c --- /dev/null +++ b/src/shared/lidoPreview/components/strategies/lidoUniV2Liquidity.ts @@ -0,0 +1,121 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Lido UniV2LiquidityDispatchStrategy inspector — reads the second budget +// (streamed wstETH), the V2 router, the price oracle, the epoch provider, the +// LP recipient, and the per-epoch lockout slot. + +import { type Address, parseAbi } from 'viem'; +import { uniV2LiquidityDispatchStrategyAbi } from '../../abi/generated/UniV2LiquidityDispatchStrategy'; +import { + dispatchBudget, + dispatchEpochProvider, +} from '../../introspect/dispatch'; +import { registerStrategy } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { LidoUniV2LiquidityDispatchStrategyNode } from '../../types/topology'; + +const ID = 'com.aragon.lido.strategy.dispatch.univ2-liquidity'; + +const baseAbi = parseAbi([ + 'function paused() view returns (bool)', + 'function budget() view returns (address)', +]); + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [ + paused, + budgetAAddress, + budgetBAddress, + router, + oracle, + epochProviderAddress, + lpRecipient, + maxSlippageBps, + maxStaleness, + lastEpoch, + ] = await Promise.all([ + ctx.client.readContract({ + address, + abi: baseAbi, + functionName: 'paused', + }), + ctx.client.readContract({ + address, + abi: baseAbi, + functionName: 'budget', + }), + ctx.client.readContract({ + address, + abi: uniV2LiquidityDispatchStrategyAbi, + functionName: 'budgetB', + }), + ctx.client.readContract({ + address, + abi: uniV2LiquidityDispatchStrategyAbi, + functionName: 'router', + }), + ctx.client.readContract({ + address, + abi: uniV2LiquidityDispatchStrategyAbi, + functionName: 'oracle', + }), + ctx.client.readContract({ + address, + abi: uniV2LiquidityDispatchStrategyAbi, + functionName: 'epochProvider', + }), + ctx.client.readContract({ + address, + abi: uniV2LiquidityDispatchStrategyAbi, + functionName: 'lpRecipient', + }), + ctx.client.readContract({ + address, + abi: uniV2LiquidityDispatchStrategyAbi, + functionName: 'maxSlippageBps', + }), + ctx.client.readContract({ + address, + abi: uniV2LiquidityDispatchStrategyAbi, + functionName: 'maxStaleness', + }), + ctx.client.readContract({ + address, + abi: uniV2LiquidityDispatchStrategyAbi, + functionName: 'lastEpoch', + }), + ]); + + const [budget, budgetB, epochProvider] = await Promise.all([ + dispatchBudget(budgetAAddress, ctx), + dispatchBudget(budgetBAddress, ctx), + dispatchEpochProvider(epochProviderAddress, ctx), + ]); + + return { + kind: 'strategy.dispatch.lido.univ2-liquidity', + address, + strategyId: ID, + paused, + budget, + budgetB, + router, + oracle, + epochProvider, + lpRecipient, + maxSlippageBps: Number(maxSlippageBps), + maxStaleness: BigInt(maxStaleness), + lastEpoch: BigInt(lastEpoch), + }; +} + +registerStrategy({ + id: ID, + kind: 'strategy.dispatch.lido.univ2-liquidity', + inspect, +}); diff --git a/src/shared/lidoPreview/components/strategies/lidoWrap.ts b/src/shared/lidoPreview/components/strategies/lidoWrap.ts new file mode 100644 index 0000000000..49c43f0b22 --- /dev/null +++ b/src/shared/lidoPreview/components/strategies/lidoWrap.ts @@ -0,0 +1,63 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Lido WrapDispatchStrategy inspector — reads the wstETH wrapper address +// alongside the inherited `paused` + `budget` from DispatchStrategyBase. + +import { type Address, parseAbi } from 'viem'; +import { wrapDispatchStrategyAbi } from '../../abi/generated/WrapDispatchStrategy'; +import { dispatchBudget } from '../../introspect/dispatch'; +import { fetchTokenInfo } from '../../introspect/token'; +import { registerStrategy } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { LidoWrapDispatchStrategyNode } from '../../types/topology'; + +const ID = 'com.aragon.lido.strategy.dispatch.wrap'; + +// Inherited from DispatchStrategyBase. Kept inline so this file doesn't +// have to import a CR base-contract ABI we don't have generated. +const baseAbi = parseAbi([ + 'function paused() view returns (bool)', + 'function budget() view returns (address)', +]); + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [paused, budgetAddress, wstETH] = await Promise.all([ + ctx.client.readContract({ + address, + abi: baseAbi, + functionName: 'paused', + }), + ctx.client.readContract({ + address, + abi: baseAbi, + functionName: 'budget', + }), + ctx.client.readContract({ + address, + abi: wrapDispatchStrategyAbi, + functionName: 'wstETH', + }), + ]); + + const [budget, wstETHToken] = await Promise.all([ + dispatchBudget(budgetAddress, ctx), + fetchTokenInfo(ctx.client, wstETH), + ]); + + return { + kind: 'strategy.dispatch.lido.wrap', + address, + strategyId: ID, + paused, + budget, + wstETH, + wstETHToken, + }; +} + +registerStrategy({ id: ID, kind: 'strategy.dispatch.lido.wrap', inspect }); diff --git a/src/shared/lidoPreview/components/strategies/transferDispatch.ts b/src/shared/lidoPreview/components/strategies/transferDispatch.ts new file mode 100644 index 0000000000..490f55c703 --- /dev/null +++ b/src/shared/lidoPreview/components/strategies/transferDispatch.ts @@ -0,0 +1,58 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import { transferDispatchStrategyAbi } from '../../abi/generated/TransferDispatchStrategy'; +import { dispatchBudget, dispatchSplitter } from '../../introspect/dispatch'; +import { registerStrategy } from '../../registry/index'; +import type { IntrospectionContext } from '../../registry/types'; +import type { TransferDispatchStrategyNode } from '../../types/topology'; + +const ID = 'org.aragon.strategy.dispatch.transfer'; + +async function inspect( + address: Address, + ctx: IntrospectionContext, +): Promise { + const [paused, budgetAddress, splitterAddress, useSafeTransfer] = + await Promise.all([ + ctx.client.readContract({ + address, + abi: transferDispatchStrategyAbi, + functionName: 'paused', + }), + ctx.client.readContract({ + address, + abi: transferDispatchStrategyAbi, + functionName: 'budget', + }), + ctx.client.readContract({ + address, + abi: transferDispatchStrategyAbi, + functionName: 'splitter', + }), + ctx.client.readContract({ + address, + abi: transferDispatchStrategyAbi, + functionName: 'useSafeTransfer', + }), + ]); + + const [budget, splitter] = await Promise.all([ + dispatchBudget(budgetAddress, ctx), + dispatchSplitter(splitterAddress, ctx), + ]); + + return { + kind: 'strategy.dispatch.transfer', + address, + strategyId: ID, + paused, + budget, + splitter, + useSafeTransfer, + }; +} + +registerStrategy({ id: ID, kind: 'strategy.dispatch.transfer', inspect }); diff --git a/src/shared/lidoPreview/index.ts b/src/shared/lidoPreview/index.ts new file mode 100644 index 0000000000..9bf49ba441 --- /dev/null +++ b/src/shared/lidoPreview/index.ts @@ -0,0 +1,27 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// @aragon/lido-preview — public library surface. +// +// Importing this module registers every built-in component with the registry +// as a side effect. + +import './components/index'; + +export * from './abi/generated/index'; +export { type InspectOptions, inspect } from './introspect/index'; +export * from './registry/index'; +export { + type GraphEdge, + type GraphNode, + type ReactFlowGraph, + toReactFlowGraph, +} from './render/reactFlow'; +export { + type ChainState, + fetchChainState, + type SimulateOptions, + simulate, +} from './simulate/index'; +export * from './types/index'; diff --git a/src/shared/lidoPreview/introspect/dispatch.ts b/src/shared/lidoPreview/introspect/dispatch.ts new file mode 100644 index 0000000000..d9b7fc382e --- /dev/null +++ b/src/shared/lidoPreview/introspect/dispatch.ts @@ -0,0 +1,170 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Generic dispatchers: read the component's *Id() string, look it up in the +// registry, delegate to the matching inspector. Fall back to the unknown-* +// inspector when no match is registered. +// +// Note: inline `parseAbi` mini-ABIs instead of the generated interface ABIs. +// foundry (with via_ir) compiles the `external pure` id-getters into the +// interface JSON with `stateMutability: nonpayable`, which viem refuses to +// route through `readContract`. Hand-written ABI fragments avoid the bad +// mutability and keep the introspection layer independent of forge output +// shape. + +import { type Address, parseAbi } from 'viem'; +import { + findBudget, + findPlugin, + findSplitter, + findStrategy, +} from '../registry/index'; +import type { IntrospectionContext } from '../registry/types'; +import type { + BudgetNode, + EpochProviderNode, + PluginNode, + SplitterNode, + StrategyNode, +} from '../types/topology'; +import { + inspectUnknownBudget, + inspectUnknownPlugin, + inspectUnknownSplitter, + inspectUnknownStrategy, +} from './unknown'; + +const pluginIdAbi = parseAbi(['function pluginId() view returns (string)']); +const strategyIdAbi = parseAbi(['function strategyId() view returns (string)']); +const budgetIdAbi = parseAbi(['function budgetId() view returns (string)']); +const splitterIdAbi = parseAbi(['function splitterId() view returns (string)']); +const getEpochAbi = parseAbi(['function getEpoch() view returns (uint256)']); + +export async function dispatchPlugin( + address: Address, + ctx: IntrospectionContext, +): Promise { + const id = await ctx.client + .readContract({ + address, + abi: pluginIdAbi, + functionName: 'pluginId', + }) + .catch(() => null); + + if (id === null) { + ctx.warn( + 'plugin.id-read-failed', + `pluginId() call reverted at ${address} — treating as unknown plugin`, + ); + return inspectUnknownPlugin(address, null, ctx); + } + + const inspector = findPlugin(id); + if (inspector) { + return inspector.inspect(address, ctx); + } + + ctx.warn( + 'plugin.unregistered', + `No inspector registered for pluginId "${id}" at ${address}`, + ); + return inspectUnknownPlugin(address, id, ctx); +} + +export async function dispatchStrategy( + address: Address, + ctx: IntrospectionContext, +): Promise { + const id = await ctx.client.readContract({ + address, + abi: strategyIdAbi, + functionName: 'strategyId', + }); + + const inspector = findStrategy(id); + if (inspector) { + return inspector.inspect(address, ctx); + } + + ctx.warn( + 'strategy.unregistered', + `No inspector registered for strategyId "${id}" at ${address}`, + ); + return inspectUnknownStrategy(address, id, ctx); +} + +export async function dispatchBudget( + address: Address, + ctx: IntrospectionContext, +): Promise { + const id = await ctx.client.readContract({ + address, + abi: budgetIdAbi, + functionName: 'budgetId', + }); + + const inspector = findBudget(id); + if (inspector) { + return inspector.inspect(address, ctx); + } + + ctx.warn( + 'budget.unregistered', + `No inspector registered for budgetId "${id}" at ${address}`, + ); + return inspectUnknownBudget(address, id, ctx); +} + +export async function dispatchSplitter( + address: Address, + ctx: IntrospectionContext, +): Promise { + const id = await ctx.client.readContract({ + address, + abi: splitterIdAbi, + functionName: 'splitterId', + }); + + const inspector = findSplitter(id); + if (inspector) { + return inspector.inspect(address, ctx); + } + + ctx.warn( + 'splitter.unregistered', + `No inspector registered for splitterId "${id}" at ${address}`, + ); + return inspectUnknownSplitter(address, id, ctx); +} + +/** + * Epoch providers have no *Id() convention — there are many valid + * implementations (gauges, tallies, time-based counters). We just read + * `getEpoch()` and surface the current value. + */ +export async function dispatchEpochProvider( + address: Address, + ctx: IntrospectionContext, +): Promise { + const currentEpoch = await ctx.client + .readContract({ + address, + abi: getEpochAbi, + functionName: 'getEpoch', + }) + .catch((err: unknown) => { + ctx.warn( + 'epoch-provider.read-failed', + `getEpoch() reverted at ${address}: ${String(err)}`, + ); + return 0n; + }); + + return { + kind: 'epoch-provider', + address, + currentEpoch, + }; +} diff --git a/src/shared/lidoPreview/introspect/index.ts b/src/shared/lidoPreview/introspect/index.ts new file mode 100644 index 0000000000..8c52c11964 --- /dev/null +++ b/src/shared/lidoPreview/introspect/index.ts @@ -0,0 +1,63 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address, PublicClient } from 'viem'; +import type { IntrospectionContext } from '../registry/types'; +import type { TopologyGraph, Warning } from '../types/topology'; +import { dispatchPlugin } from './dispatch'; + +export type InspectOptions = { + /** Block number to read at. Defaults to the chain's current block. */ + blockNumber?: bigint; +}; + +/** + * Reads a deployed Capital Router plugin and returns its structural topology. + * Unknown components are preserved as `*.unknown` nodes rather than errors. + */ +export async function inspect( + client: PublicClient, + pluginAddress: Address, + options: InspectOptions = {}, +): Promise { + const [chainId, blockNumber] = await Promise.all([ + client.getChainId(), + options.blockNumber !== undefined + ? Promise.resolve(options.blockNumber) + : client.getBlockNumber(), + ]); + + const warnings: Warning[] = []; + const ctx: IntrospectionContext = { + client, + chainId, + blockNumber, + warn: (code, message, path) => { + warnings.push( + path === undefined + ? { code, message } + : { code, message, path }, + ); + }, + }; + + const root = await dispatchPlugin(pluginAddress, ctx); + + return { + version: 1, + chainId, + fetchedAtBlock: blockNumber, + fetchedAt: new Date().toISOString(), + root, + warnings, + }; +} + +export { + dispatchBudget, + dispatchEpochProvider, + dispatchPlugin, + dispatchSplitter, + dispatchStrategy, +} from './dispatch'; diff --git a/src/shared/lidoPreview/introspect/token.ts b/src/shared/lidoPreview/introspect/token.ts new file mode 100644 index 0000000000..094dff8cff --- /dev/null +++ b/src/shared/lidoPreview/introspect/token.ts @@ -0,0 +1,51 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import { type Address, type PublicClient, parseAbi, zeroAddress } from 'viem'; +import type { TokenInfo } from '../types/primitives'; + +// IERC20 is not a Capital Router contract, so no generated ABI exists. These +// two methods are universal ERC20 getters; a minimal local ABI is the +// straightforward way to read them. +const erc20MetadataAbi = parseAbi([ + 'function symbol() view returns (string)', + 'function decimals() view returns (uint8)', +]); + +/** + * Best-effort ERC20 metadata read. Native ETH (`address(0)`) is hard-coded. + * Non-compliant tokens that revert on either getter return null for that + * field — the caller still gets a usable node. + */ +export async function fetchTokenInfo( + client: PublicClient, + address: Address, +): Promise { + if (address === zeroAddress) { + return { address, symbol: 'ETH', decimals: 18 }; + } + + const [symbol, decimals] = await Promise.all([ + client + .readContract({ + address, + abi: erc20MetadataAbi, + functionName: 'symbol', + }) + .catch(() => null), + client + .readContract({ + address, + abi: erc20MetadataAbi, + functionName: 'decimals', + }) + .catch(() => null), + ]); + + return { + address, + symbol, + decimals: decimals === null ? null : Number(decimals), + }; +} diff --git a/src/shared/lidoPreview/introspect/unknown.ts b/src/shared/lidoPreview/introspect/unknown.ts new file mode 100644 index 0000000000..e1124c48f3 --- /dev/null +++ b/src/shared/lidoPreview/introspect/unknown.ts @@ -0,0 +1,128 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Fallback inspectors for components whose *Id() string is not in the +// registry. They extract whatever generic data the interfaces guarantee and +// flag the node explicitly so the UI can render it as a black box. + +import type { Address } from 'viem'; +import { dispatcherPluginAbi } from '../abi/generated/DispatcherPlugin'; +import { iDispatchBudgetAbi } from '../abi/generated/IDispatchBudget'; +import { iDispatchStrategyAbi } from '../abi/generated/IDispatchStrategy'; +import type { IntrospectionContext } from '../registry/types'; +import type { + UnknownBudgetNode, + UnknownPluginNode, + UnknownSplitterNode, + UnknownStrategyNode, +} from '../types/topology'; +import { dispatchBudget } from './dispatch'; +import { fetchTokenInfo } from './token'; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address; + +export async function inspectUnknownPlugin( + address: Address, + pluginId: string | null, + _ctx: IntrospectionContext, +): Promise { + // `dao()` is declared on the OSx Plugin base. Any Aragon plugin exposes it + // with the same selector, so calling via DispatcherPlugin's ABI is safe + // duck-typing — the contract either implements the selector or reverts. + const dao = await _ctx.client + .readContract({ + address, + abi: dispatcherPluginAbi, + functionName: 'dao', + }) + .catch(() => null); + + return { + kind: 'plugin.unknown', + address, + pluginId, + dao, + }; +} + +export async function inspectUnknownStrategy( + address: Address, + strategyId: string, + ctx: IntrospectionContext, +): Promise { + const [paused, budgetAddress] = await Promise.all([ + ctx.client + .readContract({ + address, + abi: iDispatchStrategyAbi, + functionName: 'paused', + }) + .catch(() => false), + ctx.client + .readContract({ + address, + abi: iDispatchStrategyAbi, + functionName: 'budget', + }) + .catch(() => null), + ]); + + // Best-effort: if the strategy exposes a budget, recurse into it. A known + // budget under an unknown strategy is still useful for the UI. + const budget = budgetAddress + ? await dispatchBudget(budgetAddress, ctx).catch(() => null) + : null; + + return { + kind: 'strategy.unknown', + address, + strategyId, + paused, + budget, + }; +} + +export async function inspectUnknownBudget( + address: Address, + budgetId: string, + ctx: IntrospectionContext, +): Promise { + const tokenAddress = await ctx.client + .readContract({ + address, + abi: iDispatchBudgetAbi, + functionName: 'token', + }) + .catch(() => null); + + if (!tokenAddress) { + ctx.warn( + 'budget.unknown.token-read-failed', + `token() reverted at unknown budget ${address}`, + ); + } + + const token = tokenAddress + ? await fetchTokenInfo(ctx.client, tokenAddress) + : { address: ZERO_ADDRESS, symbol: null, decimals: null }; + + return { + kind: 'budget.unknown', + address, + budgetId, + token, + }; +} + +export async function inspectUnknownSplitter( + address: Address, + splitterId: string, + _ctx: IntrospectionContext, +): Promise { + return { + kind: 'splitter.unknown', + address, + splitterId, + }; +} diff --git a/src/shared/lidoPreview/registry/index.ts b/src/shared/lidoPreview/registry/index.ts new file mode 100644 index 0000000000..8f1f9efdb8 --- /dev/null +++ b/src/shared/lidoPreview/registry/index.ts @@ -0,0 +1,86 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Registry of known components. Each built-in registers itself on import. +// Unknown *Id() strings are served by the `unknown-*` inspectors, which live +// here too (registered with an empty id that dispatch functions never match). + +import type { + BudgetInspector, + PluginInspector, + SplitterInspector, + StrategyInspector, + StrategyPredictor, +} from './types'; + +const plugins = new Map(); +const strategies = new Map(); +const budgets = new Map(); +const splitters = new Map(); +const predictors = new Map(); + +// Registration -------------------------------------------------------------- + +export function registerPlugin(inspector: PluginInspector): void { + plugins.set(inspector.id, inspector); +} + +export function registerStrategy(inspector: StrategyInspector): void { + strategies.set(inspector.id, inspector); +} + +export function registerBudget(inspector: BudgetInspector): void { + budgets.set(inspector.id, inspector); +} + +export function registerSplitter(inspector: SplitterInspector): void { + splitters.set(inspector.id, inspector); +} + +export function registerPredictor(predictor: StrategyPredictor): void { + predictors.set(predictor.kind, predictor); +} + +// Lookup -------------------------------------------------------------------- + +export function findPlugin(id: string): PluginInspector | undefined { + return plugins.get(id); +} + +export function findStrategy(id: string): StrategyInspector | undefined { + return strategies.get(id); +} + +export function findBudget(id: string): BudgetInspector | undefined { + return budgets.get(id); +} + +export function findSplitter(id: string): SplitterInspector | undefined { + return splitters.get(id); +} + +export function findPredictor(kind: string): StrategyPredictor | undefined { + return predictors.get(kind); +} + +// Introspection -------------------------------------------------------------- + +/** Every plugin id the registry can inspect specifically (not via unknown fallback). */ +export function listPluginIds(): string[] { + return [...plugins.keys()]; +} + +export function listStrategyIds(): string[] { + return [...strategies.keys()]; +} + +export function listBudgetIds(): string[] { + return [...budgets.keys()]; +} + +export function listSplitterIds(): string[] { + return [...splitters.keys()]; +} + +export type * from './types'; diff --git a/src/shared/lidoPreview/registry/types.ts b/src/shared/lidoPreview/registry/types.ts new file mode 100644 index 0000000000..8a3e584ef3 --- /dev/null +++ b/src/shared/lidoPreview/registry/types.ts @@ -0,0 +1,105 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address, PublicClient } from 'viem'; +import type { ChainState } from '../simulate/chainState'; +import type { ExternalCall, StepStatus, Transfer } from '../types/flow'; +import type { Provenance, TokenInfo } from '../types/primitives'; +import type { + BudgetNode, + PluginNode, + SplitterNode, + StrategyNode, +} from '../types/topology'; + +/** + * Shared context threaded through introspection. Inspectors use it to read + * from chain and to record non-fatal issues (the returned graph still holds + * a partial node; warnings surface alongside). + */ +export type IntrospectionContext = { + client: PublicClient; + chainId: number; + blockNumber: bigint; + warn: (code: string, message: string, path?: string) => void; +}; + +/** + * Shared context threaded through simulation. Predictors use it to record + * issues attached to the step they're producing and to access the surrounding + * topology (the DAO vault address, the current step index). + */ +export type SimulationContext = { + client: PublicClient; + chainId: number; + blockNumber: bigint; + /** The DAO address — the vault that holds the tokens being dispatched. */ + vault: Address; + /** Index of the strategy currently being predicted. */ + stepIndex: number; + warn: (code: string, message: string, stepIndex?: number) => void; +}; + +// --- Inspectors ------------------------------------------------------------- + +export type PluginInspector = { + id: string; + kind: PluginNode['kind']; + inspect: ( + address: Address, + ctx: IntrospectionContext, + ) => Promise; +}; + +export type StrategyInspector = { + id: string; + kind: StrategyNode['kind']; + inspect: ( + address: Address, + ctx: IntrospectionContext, + ) => Promise; +}; + +export type BudgetInspector = { + id: string; + kind: BudgetNode['kind']; + inspect: ( + address: Address, + ctx: IntrospectionContext, + ) => Promise; +}; + +export type SplitterInspector = { + id: string; + kind: SplitterNode['kind']; + inspect: ( + address: Address, + ctx: IntrospectionContext, + ) => Promise; +}; + +// --- Predictors ------------------------------------------------------------- + +/** + * What a predictor tells the simulator. The simulator owns the ChainState + * and converts the result into a Step (filling in `before` / `after` from + * its own book-keeping). + */ +export type PredictResult = { + status: StepStatus; + reason?: string; + budget?: { amount: bigint; token: TokenInfo; provenance: Provenance }; + transfers: Transfer[]; + externalCalls: ExternalCall[]; + notes: string[]; +}; + +export type StrategyPredictor = { + kind: StrategyNode['kind']; + predict: ( + node: StrategyNode, + state: ChainState, + ctx: SimulationContext, + ) => Promise; +}; diff --git a/src/shared/lidoPreview/render/reactFlow.ts b/src/shared/lidoPreview/render/reactFlow.ts new file mode 100644 index 0000000000..5b818f8c37 --- /dev/null +++ b/src/shared/lidoPreview/render/reactFlow.ts @@ -0,0 +1,409 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Projection from a TopologyGraph to a React Flow-compatible `{nodes, edges}` +// graph. No layout is embedded — consumers feed this to a layout algorithm +// (dagre, elkjs, etc.) and render. The `type` field is the node's `kind` so +// consumers can style per-component. +// +// Shared infra (e.g. one EpochProvider used by both a strategy and a +// StreamUntilBudget, or a `recipient` address appearing in multiple +// splitters) is collapsed into a single node by `(kind, address)`. This +// keeps the inspect data tree-shaped (each consumer self-introspects) while +// the projection reflects the contract reality of one shared instance. + +import type { + BudgetNode, + EpochProviderNode, + PluginNode, + PriceFloorGateNode, + SplitterNode, + StrategyNode, + TopologyGraph, +} from '../types/topology'; + +export type GraphNode = { + id: string; + type: string; + data: Record; + position: { x: number; y: number }; +}; + +export type GraphEdge = { + id: string; + source: string; + target: string; + type?: string; + label?: string; + data?: Record; +}; + +export type ReactFlowGraph = { + nodes: GraphNode[]; + edges: GraphEdge[]; +}; + +type AddEdge = ( + id: string, + source: string, + target: string, + label?: string, + type?: string, +) => void; + +/** A function that emits (or dedupes) a node and returns the id callers + * should connect edges to. When `address` is omitted the node is treated + * as unique (no dedupe). First write wins for the node data — every + * caller passes the same shape per `(kind, address)` by construction. */ +type AddNode = ( + suggestedId: string, + kind: string, + address: string | undefined, + data: Record, +) => string; + +/** + * Emit a React Flow graph describing the structural topology. Node ids are + * stable across runs (`/strategies//budget`, etc.) so the UI can + * keep selection/positions stable between re-fetches. + */ +export function toReactFlowGraph(topology: TopologyGraph): ReactFlowGraph { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const canonical = new Map(); + + const addNode: AddNode = (suggestedId, kind, address, data) => { + if (address !== undefined) { + const key = `${kind}|${address.toLowerCase()}`; + const existing = canonical.get(key); + if (existing !== undefined) { + return existing; + } + canonical.set(key, suggestedId); + } + nodes.push({ + id: suggestedId, + type: kind, + data, + position: { x: 0, y: 0 }, + }); + return suggestedId; + }; + const addEdge: AddEdge = (id, source, target, label, type) => { + edges.push({ + id, + source, + target, + ...(label === undefined ? {} : { label }), + ...(type === undefined ? {} : { type }), + }); + }; + + // For DispatcherPlugin topologies, emit a DAO node as a parent of the + // plugin so the wiring reads "DAO owns this plugin, which composes these + // strategies." Unknown plugins skip the DAO wrap-around (no `dao` field + // is guaranteed). + if (topology.root.kind === 'plugin.dispatch') { + const daoId = addNode('root/dao', 'dao', topology.root.dao, { + address: topology.root.dao, + }); + const pluginId = visitPlugin(topology.root, 'root', addNode, addEdge); + addEdge(`${daoId}->${pluginId}`, daoId, pluginId, 'owns', 'owns'); + } else { + visitPlugin(topology.root, 'root', addNode, addEdge); + } + + // Edges can collide when their source/target are nodes that got + // deduplicated by `(kind, address)`. Concrete case: a single + // StreamUntilBudget shared between two strategies (UniV2 `budgetB` + + // GatedCowSwap `budget` in the LMM deployment) produces the same + // `${budgetId}->${epochProviderId}` edge twice — first emit wins, just + // like the node dedup contract above. + const seenEdgeIds = new Set(); + const dedupedEdges = edges.filter((e) => { + if (seenEdgeIds.has(e.id)) { + return false; + } + seenEdgeIds.add(e.id); + return true; + }); + + return { nodes, edges: dedupedEdges }; +} + +function visitPlugin( + node: PluginNode, + suggestedId: string, + addNode: AddNode, + addEdge: AddEdge, +): string { + switch (node.kind) { + case 'plugin.dispatch': { + const id = addNode(suggestedId, node.kind, node.address, { + address: node.address, + pluginId: node.pluginId, + dao: node.dao, + strategyCount: node.strategies.length, + failsafeStrategyMap: node.failsafeStrategyMap, + }); + node.strategies.forEach((strategy, i) => { + const strategyId = visitStrategy( + strategy, + `${suggestedId}/strategies/${i}`, + addNode, + addEdge, + ); + addEdge( + `${id}->${strategyId}`, + id, + strategyId, + `[${i}]`, + 'composes', + ); + }); + return id; + } + case 'plugin.unknown': + return addNode(suggestedId, node.kind, node.address, { + address: node.address, + pluginId: node.pluginId, + dao: node.dao, + }); + } +} + +function visitStrategy( + node: StrategyNode, + suggestedId: string, + addNode: AddNode, + addEdge: AddEdge, +): string { + // Per-kind extras: every node kind selectively contributes only the fields + // it actually defines (no `as any` shenanigans, just a careful object + // construction via the discriminant). + const data: Record = { + address: node.address, + strategyId: node.strategyId, + paused: node.paused, + }; + switch (node.kind) { + case 'strategy.dispatch.transfer': + data.useSafeTransfer = node.useSafeTransfer; + break; + case 'strategy.dispatch.epoch-transfer': + data.useSafeTransfer = node.useSafeTransfer; + data.lastDispatchedEpoch = node.lastDispatchedEpoch; + break; + case 'strategy.dispatch.lido.wrap': + data.wstETH = node.wstETH; + break; + case 'strategy.dispatch.lido.univ2-liquidity': + data.router = node.router; + data.oracle = node.oracle; + data.lpRecipient = node.lpRecipient; + data.maxSlippageBps = node.maxSlippageBps; + data.maxStaleness = node.maxStaleness; + data.lastEpoch = node.lastEpoch; + break; + case 'strategy.dispatch.lido.gated-cowswap': + data.targetToken = node.targetToken; + data.cowSwapSettlement = node.cowSwapSettlement; + data.priceOracle = node.priceOracle; + data.maxSlippageBps = node.maxSlippageBps; + data.maxStaleness = node.maxStaleness; + data.useSafeApproval = node.useSafeApproval; + data.lastEpoch = node.lastEpoch; + break; + case 'strategy.dispatch.burn': + case 'strategy.unknown': + break; + } + const id = addNode(suggestedId, node.kind, node.address, data); + + // Primary budget edge (every strategy except `strategy.unknown` whose + // budget might be null). + if ('budget' in node && node.budget) { + const budgetId = visitBudget( + node.budget, + `${suggestedId}/budget`, + addNode, + addEdge, + ); + addEdge(`${id}->${budgetId}`, id, budgetId, 'budget', 'reads-from'); + } + // Secondary budget for UniV2 (LDO + wstETH stream). + if (node.kind === 'strategy.dispatch.lido.univ2-liquidity') { + const budgetBId = visitBudget( + node.budgetB, + `${suggestedId}/budgetB`, + addNode, + addEdge, + ); + addEdge(`${id}->${budgetBId}`, id, budgetBId, 'budgetB', 'reads-from'); + } + // Splitter (CR Transfer / EpochTransfer). + if ('splitter' in node) { + const splitterId = visitSplitter( + node.splitter, + `${suggestedId}/splitter`, + addNode, + addEdge, + ); + addEdge( + `${id}->${splitterId}`, + id, + splitterId, + 'splitter', + 'reads-from', + ); + } + // Soft gate (Lido GatedCowSwap). + if (node.kind === 'strategy.dispatch.lido.gated-cowswap') { + const gateId = visitGate(node.gate, `${suggestedId}/gate`, addNode); + addEdge(`${id}->${gateId}`, id, gateId, 'gate', 'reads-from'); + } + // Epoch provider. + if ('epochProvider' in node) { + const epochId = visitEpochProvider( + node.epochProvider, + `${suggestedId}/epochProvider`, + addNode, + ); + addEdge(`${id}->${epochId}`, id, epochId, 'epoch', 'reads-from'); + } + return id; +} + +function visitBudget( + node: BudgetNode, + suggestedId: string, + addNode: AddNode, + addEdge: AddEdge, +): string { + const data: Record = { + address: node.address, + budgetId: node.budgetId, + token: node.token, + }; + switch (node.kind) { + case 'budget.full': + data.vault = node.vault; + break; + case 'budget.required': + data.vault = node.vault; + data.requiredAmount = node.requiredAmount; + break; + case 'budget.lido.stream-until': + data.vault = node.vault; + data.targetEpoch = node.targetEpoch; + data.floorEpochs = node.floorEpochs; + break; + case 'budget.unknown': + break; + } + const id = addNode(suggestedId, node.kind, node.address, data); + + // The StreamUntil budget reads from an epoch provider — surface that edge + // so the topology shows what drives the per-tick share. + if (node.kind === 'budget.lido.stream-until') { + const epochId = visitEpochProvider( + node.epochProvider, + `${suggestedId}/epochProvider`, + addNode, + ); + addEdge(`${id}->${epochId}`, id, epochId, 'epoch', 'reads-from'); + } + return id; +} + +function visitGate( + node: PriceFloorGateNode, + suggestedId: string, + addNode: AddNode, +): string { + return addNode(suggestedId, node.kind, node.address, { + address: node.address, + vault: node.vault, + oracle: node.oracle, + tokenA: node.tokenA, + tokenB: node.tokenB, + threshold: node.threshold, + maxStaleness: node.maxStaleness, + }); +} + +function visitSplitter( + node: SplitterNode, + suggestedId: string, + addNode: AddNode, + addEdge: AddEdge, +): string { + const id = addNode(suggestedId, node.kind, node.address, { + address: node.address, + splitterId: node.splitterId, + ...('recipient' in node ? { recipient: node.recipient } : {}), + ...('recipients' in node ? { recipients: node.recipients } : {}), + ...('entries' in node ? { entries: node.entries } : {}), + }); + + // Emit recipient nodes + distribution edges so a UI can draw money flow + // at the topology level (amounts come from FlowGraph at runtime). + if (node.kind === 'splitter.solo') { + const recipientId = addNode( + `${suggestedId}/recipient`, + 'recipient', + node.recipient, + { address: node.recipient }, + ); + addEdge( + `${id}->${recipientId}`, + id, + recipientId, + '100%', + 'distributes-to', + ); + } else if (node.kind === 'splitter.equal') { + const share = + node.recipients.length > 0 + ? `${(100 / node.recipients.length).toFixed(2)}%` + : '—'; + node.recipients.forEach((recipient, i) => { + const rid = addNode( + `${suggestedId}/recipients/${i}`, + 'recipient', + recipient, + { address: recipient }, + ); + addEdge(`${id}->${rid}`, id, rid, share, 'distributes-to'); + }); + } else if (node.kind === 'splitter.ratio') { + node.entries.forEach(({ recipient, ratio }, i) => { + const rid = addNode( + `${suggestedId}/recipients/${i}`, + 'recipient', + recipient, + { address: recipient }, + ); + addEdge( + `${id}->${rid}`, + id, + rid, + `${(ratio / 10_000).toFixed(2)}%`, + 'distributes-to', + ); + }); + } + return id; +} + +function visitEpochProvider( + node: EpochProviderNode, + suggestedId: string, + addNode: AddNode, +): string { + return addNode(suggestedId, node.kind, node.address, { + address: node.address, + currentEpoch: node.currentEpoch, + }); +} diff --git a/src/shared/lidoPreview/simulate/allocations.ts b/src/shared/lidoPreview/simulate/allocations.ts new file mode 100644 index 0000000000..8218229594 --- /dev/null +++ b/src/shared/lidoPreview/simulate/allocations.ts @@ -0,0 +1,90 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import type { Provenance } from '../types/primitives'; +import type { SplitterNode } from '../types/topology'; + +const RATIO_BASE = 1_000_000n; + +export type Allocation = { + recipient: Address; + amount: bigint; + provenance: Provenance; +}; + +export type AllocationResult = { + allocations: Allocation[]; + /** The portion of the budget not distributed (remainder, dust, etc.). */ + residual: bigint; + provenance: Provenance; +}; + +/** + * Pure model of a splitter's `allocations(total)` call. Matches the Solidity + * arithmetic exactly for the built-in splitters so a simulator can predict + * exactly what a dispatch will transfer. + */ +export function splitAllocations( + node: SplitterNode, + total: bigint, +): AllocationResult { + switch (node.kind) { + case 'splitter.solo': + return { + allocations: [ + { + recipient: node.recipient, + amount: total, + provenance: 'deterministic', + }, + ], + residual: 0n, + provenance: 'deterministic', + }; + + case 'splitter.equal': { + const count = BigInt(node.recipients.length); + if (count === 0n) { + return { + allocations: [], + residual: total, + provenance: 'deterministic', + }; + } + const share = total / count; + const distributed = share * count; + return { + allocations: node.recipients.map((recipient) => ({ + recipient, + amount: share, + provenance: 'deterministic' as Provenance, + })), + residual: total - distributed, + provenance: 'deterministic', + }; + } + + case 'splitter.ratio': { + let distributed = 0n; + const allocations = node.entries.map(({ recipient, ratio }) => { + const amount = (total * BigInt(ratio)) / RATIO_BASE; + distributed += amount; + return { + recipient, + amount, + provenance: 'deterministic' as Provenance, + }; + }); + return { + allocations, + residual: total - distributed, + provenance: 'deterministic', + }; + } + + case 'splitter.unknown': + return { allocations: [], residual: total, provenance: 'opaque' }; + } +} diff --git a/src/shared/lidoPreview/simulate/budget.ts b/src/shared/lidoPreview/simulate/budget.ts new file mode 100644 index 0000000000..2c403529d7 --- /dev/null +++ b/src/shared/lidoPreview/simulate/budget.ts @@ -0,0 +1,97 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import type { Provenance, TokenInfo } from '../types/primitives'; +import type { BudgetNode } from '../types/topology'; +import { type ChainState, getBalance, getEpoch } from './chainState'; + +export type BudgetRead = { + amount: bigint; + token: TokenInfo; + provenance: Provenance; + vault: Address | null; + /** Set when the simulated read would revert on-chain. */ + wouldRevert?: { reason: string }; +}; + +/** + * Model a budget's `budget()` call purely from the topology + chain state. + * Never hits the chain — the ChainState has already been fetched. + */ +export function readBudget(node: BudgetNode, state: ChainState): BudgetRead { + switch (node.kind) { + case 'budget.full': { + const amount = getBalance(state, node.vault, node.token.address); + return { + amount, + token: node.token, + provenance: 'deterministic', + vault: node.vault, + }; + } + case 'budget.required': { + const actual = getBalance(state, node.vault, node.token.address); + const wouldRevert = + actual < node.requiredAmount + ? { + reason: `vault balance ${actual} < requiredAmount ${node.requiredAmount}`, + } + : undefined; + return { + amount: node.requiredAmount, + token: node.token, + provenance: 'deterministic', + vault: node.vault, + ...(wouldRevert === undefined ? {} : { wouldRevert }), + }; + } + case 'budget.lido.stream-until': { + // `balance / max(targetEpoch − currentEpoch, floorEpochs)`. + // The denominator's anti-spike floor caps the per-tick drain past + // `targetEpoch` at `balance / floorEpochs`. + const balance = getBalance(state, node.vault, node.token.address); + if (balance === 0n) { + return { + amount: 0n, + token: node.token, + provenance: 'deterministic', + vault: node.vault, + }; + } + const currentEpoch = getEpoch(state, node.epochProvider.address); + if (currentEpoch === undefined) { + return { + amount: 0n, + token: node.token, + provenance: 'opaque', + vault: node.vault, + wouldRevert: { + reason: 'epoch provider not in chain state', + }, + }; + } + const remaining = + node.targetEpoch > currentEpoch + ? node.targetEpoch - currentEpoch + : 0n; + const floor = BigInt(node.floorEpochs); + const denom = remaining > floor ? remaining : floor; + const amount = denom === 0n ? 0n : balance / denom; + return { + amount: amount < balance ? amount : balance, + token: node.token, + provenance: 'deterministic', + vault: node.vault, + }; + } + case 'budget.unknown': + return { + amount: 0n, + token: node.token, + provenance: 'opaque', + vault: null, + }; + } +} diff --git a/src/shared/lidoPreview/simulate/chainState.ts b/src/shared/lidoPreview/simulate/chainState.ts new file mode 100644 index 0000000000..e047cbefd7 --- /dev/null +++ b/src/shared/lidoPreview/simulate/chainState.ts @@ -0,0 +1,230 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import { type Address, type PublicClient, parseAbi, zeroAddress } from 'viem'; +import type { TokenInfo } from '../types/primitives'; +import type { AnyNode, TopologyGraph } from '../types/topology'; + +/** + * Flat chain-state snapshot used by the simulator. Balances are keyed by + * `${vault}:${token}` (both lowercased) so lookups are O(1). Epoch values + * are keyed by lowercased epoch-provider address. + * + * The shape is intentionally mutable: predictors update it step-by-step as + * they propagate deltas through the vault. + */ +export type ChainState = { + balances: Map; + epochs: Map; +}; + +const balanceKey = (vault: Address, token: Address): string => + `${vault.toLowerCase()}:${token.toLowerCase()}`; + +export function getBalance( + state: ChainState, + vault: Address, + token: Address, +): bigint { + return state.balances.get(balanceKey(vault, token)) ?? 0n; +} + +export function setBalance( + state: ChainState, + vault: Address, + token: Address, + amount: bigint, +): void { + state.balances.set(balanceKey(vault, token), amount); +} + +export function addBalance( + state: ChainState, + vault: Address, + token: Address, + delta: bigint, +): void { + setBalance(state, vault, token, getBalance(state, vault, token) + delta); +} + +export function getEpoch( + state: ChainState, + provider: Address, +): bigint | undefined { + return state.epochs.get(provider.toLowerCase()); +} + +export function setEpoch( + state: ChainState, + provider: Address, + epoch: bigint, +): void { + state.epochs.set(provider.toLowerCase(), epoch); +} + +/** + * Collect every (vault, token) pair, every unique token, and every epoch + * provider referenced anywhere in the topology. This is the minimum set of + * reads the simulator needs. + */ +export function collectReadsFromTopology(topology: TopologyGraph): { + vaultTokenPairs: { vault: Address; token: TokenInfo }[]; + tokens: TokenInfo[]; + epochProviders: Address[]; +} { + const pairs = new Map(); + const tokens = new Map(); + const providers = new Map(); + + const visit = (node: AnyNode): void => { + switch (node.kind) { + case 'plugin.dispatch': { + for (const strategy of node.strategies) { + visit(strategy); + } + return; + } + case 'plugin.unknown': + return; + // CR vanilla strategies. + case 'strategy.dispatch.transfer': + case 'strategy.dispatch.epoch-transfer': + visit(node.budget); + visit(node.splitter); + if (node.kind === 'strategy.dispatch.epoch-transfer') { + visit(node.epochProvider); + } + return; + case 'strategy.dispatch.burn': + visit(node.budget); + return; + // Lido custom strategies. + case 'strategy.dispatch.lido.wrap': + visit(node.budget); + return; + case 'strategy.dispatch.lido.univ2-liquidity': + visit(node.budget); + visit(node.budgetB); + visit(node.epochProvider); + return; + case 'strategy.dispatch.lido.gated-cowswap': + visit(node.budget); + visit(node.gate); + visit(node.epochProvider); + tokens.set( + node.targetToken.address.toLowerCase(), + node.targetToken, + ); + return; + case 'strategy.unknown': + if (node.budget) { + visit(node.budget); + } + return; + // Budgets — every kind with a `vault` adds itself to the balance map. + case 'budget.full': + case 'budget.required': { + const key = balanceKey(node.vault, node.token.address); + pairs.set(key, { vault: node.vault, token: node.token }); + tokens.set(node.token.address.toLowerCase(), node.token); + return; + } + case 'budget.lido.stream-until': { + const key = balanceKey(node.vault, node.token.address); + pairs.set(key, { vault: node.vault, token: node.token }); + tokens.set(node.token.address.toLowerCase(), node.token); + visit(node.epochProvider); + return; + } + case 'budget.unknown': + tokens.set(node.token.address.toLowerCase(), node.token); + return; + // Splitters carry no on-chain reads we need to pre-fetch here. + case 'splitter.solo': + case 'splitter.equal': + case 'splitter.ratio': + case 'splitter.unknown': + return; + // Lido gate: the price oracle isn't an epoch provider, so nothing to + // pre-fetch via the balance/epoch maps. + case 'lido.price-floor-gate': + return; + case 'epoch-provider': + providers.set(node.address.toLowerCase(), node.address); + return; + } + }; + + visit(topology.root); + + return { + vaultTokenPairs: [...pairs.values()], + tokens: [...tokens.values()], + epochProviders: [...providers.values()], + }; +} + +const erc20BalanceAbi = parseAbi([ + 'function balanceOf(address owner) view returns (uint256)', +]); + +const epochProviderAbi = parseAbi([ + 'function getEpoch() view returns (uint256)', +]); + +/** + * Read every balance and epoch referenced in the topology. For native ETH + * (`address(0)`), uses the client's eth_getBalance; for ERC20s, calls + * `balanceOf(vault)`. + */ +export async function fetchChainState( + client: PublicClient, + topology: TopologyGraph, + options: { blockNumber?: bigint } = {}, +): Promise { + const { vaultTokenPairs, epochProviders } = + collectReadsFromTopology(topology); + const blockNumber = options.blockNumber; + + const balancePromises = vaultTokenPairs.map(async ({ vault, token }) => { + const amount = + token.address === zeroAddress + ? await client.getBalance({ + address: vault, + ...(blockNumber !== undefined ? { blockNumber } : {}), + }) + : await client.readContract({ + address: token.address, + abi: erc20BalanceAbi, + functionName: 'balanceOf', + args: [vault], + ...(blockNumber !== undefined ? { blockNumber } : {}), + }); + return { vault, token, amount }; + }); + + const epochPromises = epochProviders.map(async (address) => { + const epoch = await client.readContract({ + address, + abi: epochProviderAbi, + functionName: 'getEpoch', + ...(blockNumber !== undefined ? { blockNumber } : {}), + }); + return { address, epoch }; + }); + + const [balanceResults, epochResults] = await Promise.all([ + Promise.all(balancePromises), + Promise.all(epochPromises), + ]); + + const state: ChainState = { balances: new Map(), epochs: new Map() }; + for (const { vault, token, amount } of balanceResults) { + setBalance(state, vault, token.address, amount); + } + for (const { address, epoch } of epochResults) { + setEpoch(state, address, epoch); + } + return state; +} diff --git a/src/shared/lidoPreview/simulate/index.ts b/src/shared/lidoPreview/simulate/index.ts new file mode 100644 index 0000000000..2677cfef16 --- /dev/null +++ b/src/shared/lidoPreview/simulate/index.ts @@ -0,0 +1,311 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// Public simulate() entry. Orchestrates predictors, owns the mutable +// ChainState, builds the FlowGraph. +// +// Side-effect: importing this module registers the built-in predictors +// (via `./predictors.ts`). + +import './predictors'; + +import type { Address, PublicClient } from 'viem'; +import { findPredictor } from '../registry/index'; +import type { PredictResult, SimulationContext } from '../registry/types'; +import type { + FlowGraph, + FlowWarning, + Step, + VaultSnapshot, +} from '../types/flow'; +import type { TokenInfo } from '../types/primitives'; +import type { StrategyNode, TopologyGraph } from '../types/topology'; +import { + addBalance, + type ChainState, + collectReadsFromTopology, + fetchChainState, + getBalance, + getEpoch, +} from './chainState'; + +export type SimulateOptions = { + /** Pre-fetched chain state. If omitted, `simulate()` reads from `client`. */ + chainState?: ChainState; + /** Block number to read at when fetching chain state. */ + blockNumber?: bigint; +}; + +/** + * Walk `topology` step-by-step and return the predicted money flow. Each + * step's `before` and `after` snapshots are self-contained so a UI can + * scrub through them without re-running the simulator. + */ +export async function simulate( + client: PublicClient, + topology: TopologyGraph, + options: SimulateOptions = {}, +): Promise { + if (topology.root.kind !== 'plugin.dispatch') { + // Phase 1 supports Dispatcher plugins only. The other plugin kinds are + // modelled for future phases but not yet simulated. + throw new Error( + `simulate(): plugin kind "${topology.root.kind}" is not supported yet`, + ); + } + + const plugin = topology.root; + const vault = plugin.dao; + + const [chainId, blockNumber] = await Promise.all([ + client.getChainId(), + options.blockNumber !== undefined + ? Promise.resolve(options.blockNumber) + : client.getBlockNumber(), + ]); + + const state: ChainState = + options.chainState !== undefined + ? cloneState(options.chainState) + : await fetchChainState(client, topology, { blockNumber }); + + const warnings: FlowWarning[] = []; + + const { tokens, epochProviders } = collectReadsFromTopology(topology); + const failsafeMap = plugin.failsafeStrategyMap; + + const initialState = snapshotFromState( + vault, + state, + tokens, + epochProviders, + ); + + const steps: Step[] = []; + let opaqueEncountered = false; + + for (let i = 0; i < plugin.strategies.length; i++) { + const strategy = plugin.strategies[i]!; + const failsafe = (failsafeMap & (1n << BigInt(i))) !== 0n; + + const before = snapshotFromState(vault, state, tokens, epochProviders); + + const step = await predictStep({ + strategy, + state, + ctx: buildCtx({ + client, + chainId, + blockNumber, + vault, + stepIndex: i, + warnings, + }), + before, + stepIndex: i, + opaqueEncountered, + failsafe, + }); + + applyStepToState(state, step, vault); + + const after = snapshotFromState(vault, state, tokens, epochProviders); + const resolvedStep: Step = { ...step, after }; + + if ( + resolvedStep.status === 'opaque' || + strategy.kind === 'strategy.unknown' + ) { + opaqueEncountered = true; + } + + steps.push(resolvedStep); + } + + const finalState = snapshotFromState(vault, state, tokens, epochProviders); + + return { + version: 1, + chainId, + simulatedAtBlock: blockNumber, + simulatedAt: new Date().toISOString(), + plugin: plugin.address, + initialState, + steps, + finalState, + warnings, + }; +} + +// --- Internals ------------------------------------------------------------- + +type PredictInputs = { + strategy: StrategyNode; + state: ChainState; + ctx: SimulationContext; + before: VaultSnapshot; + stepIndex: number; + opaqueEncountered: boolean; + failsafe: boolean; +}; + +async function predictStep({ + strategy, + state, + ctx, + before, + stepIndex, + opaqueEncountered, + failsafe, +}: PredictInputs): Promise { + // Unknown strategies are opaque by definition. + if (strategy.kind === 'strategy.unknown') { + return { + index: stepIndex, + strategyIndex: stepIndex, + strategyRef: { address: strategy.address, kind: strategy.kind }, + status: 'opaque', + reason: `unknown strategyId "${strategy.strategyId}"`, + transfers: [], + externalCalls: [], + before, + after: before, + notes: failsafe + ? ['marked failsafe — failure would not revert dispatch'] + : [], + }; + } + + // Any deterministic strategy that runs after an opaque one inherits the + // uncertainty for token balances it reads. + if (opaqueEncountered) { + return { + index: stepIndex, + strategyIndex: stepIndex, + strategyRef: { address: strategy.address, kind: strategy.kind }, + status: 'downstream-opaque', + reason: "a prior step was opaque — this step's inputs are uncertain", + transfers: [], + externalCalls: [], + before, + after: before, + notes: failsafe + ? ['marked failsafe — failure would not revert dispatch'] + : [], + }; + } + + const predictor = findPredictor(strategy.kind); + if (!predictor) { + ctx.warn( + 'predictor.unregistered', + `No predictor registered for kind "${strategy.kind}"`, + stepIndex, + ); + return { + index: stepIndex, + strategyIndex: stepIndex, + strategyRef: { address: strategy.address, kind: strategy.kind }, + status: 'opaque', + reason: `no predictor registered for kind "${strategy.kind}"`, + transfers: [], + externalCalls: [], + before, + after: before, + notes: [], + }; + } + + const result: PredictResult = await predictor.predict(strategy, state, ctx); + + return { + index: stepIndex, + strategyIndex: stepIndex, + strategyRef: { address: strategy.address, kind: strategy.kind }, + status: result.status, + ...(result.reason === undefined ? {} : { reason: result.reason }), + ...(result.budget === undefined ? {} : { budget: result.budget }), + transfers: result.transfers, + externalCalls: result.externalCalls, + before, + after: before, // filled in by the caller after applying the step + notes: failsafe + ? [ + ...result.notes, + 'marked failsafe — failure would not revert dispatch', + ] + : result.notes, + }; +} + +/** Mutate `state` to reflect everything a predicted step does to the vault. */ +function applyStepToState(state: ChainState, step: Step, vault: Address): void { + for (const t of step.transfers) { + addBalance(state, vault, t.token.address, -t.amount); + // We don't track recipient balances — they're not part of the vault. + } + for (const call of step.externalCalls) { + for (const c of call.consumes) { + addBalance(state, vault, c.token.address, -c.amount); + } + for (const p of call.produces) { + addBalance(state, vault, p.token.address, p.amount); + } + } +} + +function snapshotFromState( + dao: Address, + state: ChainState, + tokens: TokenInfo[], + epochProviders: Address[], +): VaultSnapshot { + const balances = tokens.map((token) => ({ + token, + amount: getBalance(state, dao, token.address), + provenance: 'deterministic' as const, + })); + const epoch = epochProviders + .map((provider) => getEpoch(state, provider)) + .find((v) => v !== undefined); + return { + dao, + balances, + ...(epoch === undefined ? {} : { epoch }), + }; +} + +function cloneState(state: ChainState): ChainState { + return { + balances: new Map(state.balances), + epochs: new Map(state.epochs), + }; +} + +function buildCtx(base: { + client: PublicClient; + chainId: number; + blockNumber: bigint; + vault: Address; + stepIndex: number; + warnings: FlowWarning[]; +}): SimulationContext { + return { + client: base.client, + chainId: base.chainId, + blockNumber: base.blockNumber, + vault: base.vault, + stepIndex: base.stepIndex, + warn: (code, message, stepIndex) => { + base.warnings.push( + stepIndex === undefined + ? { code, message } + : { code, message, stepIndex }, + ); + }, + }; +} + +export type { ChainState } from './chainState'; +export { fetchChainState } from './chainState'; diff --git a/src/shared/lidoPreview/simulate/predictors.ts b/src/shared/lidoPreview/simulate/predictors.ts new file mode 100644 index 0000000000..1506c304e7 --- /dev/null +++ b/src/shared/lidoPreview/simulate/predictors.ts @@ -0,0 +1,690 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import { parseAbi, zeroAddress } from 'viem'; +import { registerPredictor } from '../registry/index'; +import type { PredictResult, SimulationContext } from '../registry/types'; +import type { Transfer } from '../types/flow'; +import type { StrategyNode } from '../types/topology'; +import { splitAllocations } from './allocations'; +import { readBudget } from './budget'; +import { type ChainState, getEpoch } from './chainState'; + +// Predictors describe WHAT happens; the simulator owns WHERE the balances +// end up. Each one returns a PredictResult — transfers + external calls + +// status. The simulator applies them to its mutable ChainState between +// steps and builds `before` / `after` snapshots around the call. + +// --- Transfer --------------------------------------------------------------- + +registerPredictor({ + kind: 'strategy.dispatch.transfer', + predict: async (node, state, ctx) => predictTransfer(node, state, ctx), +}); + +async function predictTransfer( + node: StrategyNode, + state: ChainState, + ctx: SimulationContext, +): Promise { + if (node.kind !== 'strategy.dispatch.transfer') { + throw new Error(`predictor kind mismatch: ${node.kind}`); + } + if (node.paused) { + return skipPaused(); + } + + const budget = readBudget(node.budget, state); + if (budget.wouldRevert) { + return noOp(`budget would revert: ${budget.wouldRevert.reason}`, { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }); + } + if (budget.amount === 0n) { + return noOp('budget amount is zero', { + amount: 0n, + token: budget.token, + provenance: budget.provenance, + }); + } + + const { allocations, residual } = splitAllocations( + node.splitter, + budget.amount, + ); + const transfers = buildTransfers(ctx.vault, node, allocations); + + const notes: string[] = []; + if (residual > 0n) { + notes.push( + `residual of ${residual} remains in the vault (splitter rounding / incomplete ratios)`, + ); + } + + return { + status: 'executed', + budget: { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }, + transfers, + externalCalls: [], + notes, + }; +} + +// --- Burn ------------------------------------------------------------------- + +registerPredictor({ + kind: 'strategy.dispatch.burn', + predict: async (node, state, _ctx) => { + if (node.kind !== 'strategy.dispatch.burn') { + throw new Error(`predictor kind mismatch: ${node.kind}`); + } + if (node.paused) { + return skipPaused(); + } + + const budget = readBudget(node.budget, state); + if (budget.wouldRevert) { + return noOp(`budget would revert: ${budget.wouldRevert.reason}`, { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }); + } + if (budget.amount === 0n) { + return noOp('budget amount is zero', { + amount: 0n, + token: budget.token, + provenance: budget.provenance, + }); + } + + return { + status: 'executed', + budget: { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }, + transfers: [], + externalCalls: [ + { + to: node.budget.token.address, + description: `burn(${budget.amount})`, + produces: [], + consumes: [ + { + token: node.budget.token, + amount: budget.amount, + provenance: 'deterministic', + }, + ], + }, + ], + notes: [], + }; + }, +}); + +// --- Epoch transfer --------------------------------------------------------- + +registerPredictor({ + kind: 'strategy.dispatch.epoch-transfer', + predict: async (node, state, ctx) => { + if (node.kind !== 'strategy.dispatch.epoch-transfer') { + throw new Error(`predictor kind mismatch: ${node.kind}`); + } + if (node.paused) { + return skipPaused(); + } + + const currentEpoch = getEpoch(state, node.epochProvider.address); + if (currentEpoch === undefined) { + ctx.warn( + 'epoch-provider.missing', + `epoch provider ${node.epochProvider.address} not in chain state`, + ctx.stepIndex, + ); + return noOp('epoch provider unreadable'); + } + + if (currentEpoch <= node.lastDispatchedEpoch) { + return noOp( + `already dispatched for epoch ${node.lastDispatchedEpoch} (current: ${currentEpoch})`, + ); + } + + const budget = readBudget(node.budget, state); + if (budget.wouldRevert) { + return noOp(`budget would revert: ${budget.wouldRevert.reason}`, { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }); + } + if (budget.amount === 0n) { + return noOp('budget amount is zero', { + amount: 0n, + token: budget.token, + provenance: budget.provenance, + }); + } + + const { allocations, residual } = splitAllocations( + node.splitter, + budget.amount, + ); + const transfers = buildTransfers(ctx.vault, node, allocations); + + const notes: string[] = []; + const skipped = currentEpoch - node.lastDispatchedEpoch - 1n; + if (skipped > 0n) { + notes.push( + `${skipped} epoch(s) skipped — catch-up behaviour depends on the budget kind`, + ); + } + if (residual > 0n) { + notes.push(`residual of ${residual} remains in the vault`); + } + + return { + status: 'executed', + budget: { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }, + transfers, + externalCalls: [], + notes, + }; + }, +}); + +// --- Lido: Wrap ------------------------------------------------------------- + +const wstETHViewAbi = parseAbi([ + // wstETH.getWstETHByStETH(stETHAmount) → shares to be minted on wrap. + // (Lido's `getSharesByPooledEth` is on the stETH contract, not wstETH.) + 'function getWstETHByStETH(uint256) view returns (uint256)', +]); + +registerPredictor({ + kind: 'strategy.dispatch.lido.wrap', + predict: async (node, state, ctx) => { + if (node.kind !== 'strategy.dispatch.lido.wrap') { + throw new Error(`predictor kind mismatch: ${node.kind}`); + } + if (node.paused) { + return skipPaused(); + } + + const budget = readBudget(node.budget, state); + if (budget.wouldRevert) { + return noOp(`budget would revert: ${budget.wouldRevert.reason}`, { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }); + } + if (budget.amount === 0n) { + return noOp('budget amount is zero', { + amount: 0n, + token: budget.token, + provenance: budget.provenance, + }); + } + + // wstETH.wrap(stETHAmount) mints `getWstETHByStETH(stETHAmount)` wstETH + // back to the caller. Reading at the current block is the closest we + // can get to deterministic. + const wstETHAmount = await ctx.client.readContract({ + address: node.wstETH, + abi: wstETHViewAbi, + functionName: 'getWstETHByStETH', + args: [budget.amount], + }); + + return { + status: 'executed', + budget: { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }, + transfers: [], + externalCalls: [ + { + to: node.wstETH, + description: `wrap(${budget.amount})`, + consumes: [ + { + token: budget.token, + amount: budget.amount, + provenance: 'deterministic', + }, + ], + produces: [ + { + token: node.wstETHToken, + amount: wstETHAmount, + provenance: 'deterministic', + }, + ], + }, + ], + notes: [], + }; + }, +}); + +// --- Lido: UniV2 add-liquidity --------------------------------------------- + +const univ2FactoryAbi = parseAbi([ + 'function getPair(address tokenA, address tokenB) view returns (address)', +]); +const univ2PairAbi = parseAbi([ + 'function getReserves() view returns (uint112,uint112,uint32)', + 'function token0() view returns (address)', + 'function totalSupply() view returns (uint256)', +]); +const univ2RouterAbi = parseAbi(['function factory() view returns (address)']); +const oraclePriceAbi = parseAbi([ + 'function getPrice(address,address) view returns (uint256,uint256)', +]); + +const SLIPPAGE_BASE = 10_000n; +const PRICE_SCALE = 10n ** 18n; + +registerPredictor({ + kind: 'strategy.dispatch.lido.univ2-liquidity', + predict: async (node, state, ctx) => { + if (node.kind !== 'strategy.dispatch.lido.univ2-liquidity') { + throw new Error(`predictor kind mismatch: ${node.kind}`); + } + if (node.paused) { + return skipPaused(); + } + + const currentEpoch = getEpoch(state, node.epochProvider.address); + if (currentEpoch === undefined) { + return noOp('epoch provider unreadable'); + } + if (currentEpoch === node.lastEpoch) { + return noOp('same epoch — slot already burned'); + } + + const budgetA = readBudget(node.budget, state); + const budgetB = readBudget(node.budgetB, state); + if (budgetA.wouldRevert) { + return noOp(`budgetA would revert: ${budgetA.wouldRevert.reason}`); + } + if (budgetB.wouldRevert) { + return noOp(`budgetB would revert: ${budgetB.wouldRevert.reason}`); + } + if (budgetA.amount === 0n) { + return noOp(`${budgetA.token.symbol ?? 'side A'} budget is 0`); + } + if (budgetB.amount === 0n) { + return noOp(`${budgetB.token.symbol ?? 'side B'} budget is 0`); + } + + // Oracle price (LDO → wstETH). If unreadable, surface opaque. + let priceAB: bigint; + let priceUpdatedAt: bigint; + try { + const [price, updatedAt] = await ctx.client.readContract({ + address: node.oracle, + abi: oraclePriceAbi, + functionName: 'getPrice', + args: [budgetA.token.address, budgetB.token.address], + }); + priceAB = price; + priceUpdatedAt = updatedAt; + } catch { + return { + status: 'opaque', + reason: `oracle.getPrice(${budgetA.token.address}, ${budgetB.token.address}) reverted`, + transfers: [], + externalCalls: [], + notes: [], + }; + } + if (priceAB === 0n) { + return noOp('oracle price is 0'); + } + // Staleness — the strategy refuses to fire when `block.timestamp > + // updatedAt + maxStaleness`. Without this check the simulator + // predicts a phantom dispatch while on-chain the strategy + // silently returns empty actions (same shape as the gate closing + // for CowSwap, but without a visible knob). + const block = await ctx.client.getBlock(); + if ( + node.maxStaleness !== 0n && + block.timestamp > priceUpdatedAt + node.maxStaleness + ) { + return noOp( + `oracle stale: ${block.timestamp - priceUpdatedAt}s old, max ${node.maxStaleness}s`, + ); + } + + // Pool reserves + on-chain ratio gate (matches the strategy's + // `_poolRatioWithinSlippage` check). + const factory = await ctx.client.readContract({ + address: node.router, + abi: univ2RouterAbi, + functionName: 'factory', + }); + const pair = await ctx.client.readContract({ + address: factory, + abi: univ2FactoryAbi, + functionName: 'getPair', + args: [budgetA.token.address, budgetB.token.address], + }); + if (pair === zeroAddress) { + return noOp( + 'UniV2 pair not deployed — strategy refuses cold-start', + ); + } + const [reserve0Raw, reserve1Raw] = await ctx.client.readContract({ + address: pair, + abi: univ2PairAbi, + functionName: 'getReserves', + }); + const token0 = await ctx.client.readContract({ + address: pair, + abi: univ2PairAbi, + functionName: 'token0', + }); + const reserveA = + token0.toLowerCase() === budgetA.token.address.toLowerCase() + ? BigInt(reserve0Raw) + : BigInt(reserve1Raw); + const reserveB = + token0.toLowerCase() === budgetA.token.address.toLowerCase() + ? BigInt(reserve1Raw) + : BigInt(reserve0Raw); + if (reserveA === 0n || reserveB === 0n) { + return noOp('pool reserves are zero'); + } + const poolPrice = (reserveB * PRICE_SCALE) / reserveA; + const slippage = BigInt(node.maxSlippageBps); + const upper = (priceAB * (SLIPPAGE_BASE + slippage)) / SLIPPAGE_BASE; + const lower = (priceAB * (SLIPPAGE_BASE - slippage)) / SLIPPAGE_BASE; + if (poolPrice > upper || poolPrice < lower) { + return noOp( + `pool ratio outside slippage band: pool=${poolPrice}, oracle=${priceAB}, ±${node.maxSlippageBps}bps`, + ); + } + + // Binding-side selection at the oracle ratio. + const quotedB = (budgetA.amount * priceAB) / PRICE_SCALE; + let amountA: bigint; + let amountB: bigint; + if (quotedB <= budgetB.amount) { + amountA = budgetA.amount; + amountB = quotedB; + } else { + amountA = (budgetB.amount * PRICE_SCALE) / priceAB; + amountB = budgetB.amount; + } + + // LP minted is deterministic from the pair's totalSupply + reserves: + // min(amountA·ts/reserveA, amountB·ts/reserveB). We can estimate it + // pre-dispatch (the actual mint lands on-chain), so the canvas can show + // the LP coming back to the recipient instead of a one-way arrow. + let lpEstimate: bigint | undefined; + try { + const totalSupply = await ctx.client.readContract({ + address: pair, + abi: univ2PairAbi, + functionName: 'totalSupply', + }); + if (totalSupply > 0n) { + const lpA = (amountA * totalSupply) / reserveA; + const lpB = (amountB * totalSupply) / reserveB; + lpEstimate = lpA < lpB ? lpA : lpB; + } + } catch { + // Leave undefined → opaque LP (still shown as a pending return). + } + // The pair is a standard 18-decimal UNI-V2 ERC20. + const lpToken = { address: pair, symbol: 'UNI-V2', decimals: 18 }; + const lpToVault = + node.lpRecipient.toLowerCase() === ctx.vault.toLowerCase(); + + return { + status: 'executed', + budget: { + amount: budgetA.amount, + token: budgetA.token, + provenance: budgetA.provenance, + }, + transfers: lpToVault + ? [] + : [ + { + from: ctx.vault, + to: node.lpRecipient, + token: lpToken, + amount: lpEstimate ?? 0n, + provenance: lpEstimate + ? ('estimated-via-oracle' as const) + : ('opaque' as const), + }, + ], + externalCalls: [ + { + to: node.router, + description: `addLiquidity(${budgetA.token.symbol ?? 'A'}=${amountA}, ${ + budgetB.token.symbol ?? 'B' + }=${amountB}) → LP to ${node.lpRecipient}`, + consumes: [ + { + token: budgetA.token, + amount: amountA, + provenance: 'estimated-via-oracle', + }, + { + token: budgetB.token, + amount: amountB, + provenance: 'estimated-via-oracle', + }, + ], + // LP minted back to the DAO loops in as a `produces`; LP to a + // third party is modelled as a transfer (handled above). + produces: lpToVault + ? [ + { + token: lpToken, + amount: lpEstimate ?? 0n, + provenance: lpEstimate + ? 'estimated-via-oracle' + : 'opaque', + }, + ] + : [], + }, + ], + notes: [ + lpToVault + ? `LP tokens minted back to the DAO vault${lpEstimate ? '' : ' (amount settles on-chain)'}` + : `LP tokens minted to ${node.lpRecipient}, not the DAO`, + amountA < budgetA.amount + ? `${budgetA.amount - amountA} ${budgetA.token.symbol ?? 'A'} stays in the DAO (B was binding)` + : amountB < budgetB.amount + ? `${budgetB.amount - amountB} ${budgetB.token.symbol ?? 'B'} stays in the DAO (A was binding)` + : '', + ].filter((s) => s.length > 0), + }; + }, +}); + +// --- Lido: GatedCowSwap ----------------------------------------------------- + +const priceFloorGateAbi = parseAbi(['function passes() view returns (bool)']); + +registerPredictor({ + kind: 'strategy.dispatch.lido.gated-cowswap', + predict: async (node, state, ctx) => { + if (node.kind !== 'strategy.dispatch.lido.gated-cowswap') { + throw new Error(`predictor kind mismatch: ${node.kind}`); + } + if (node.paused) { + return skipPaused(); + } + + const currentEpoch = getEpoch(state, node.epochProvider.address); + if (currentEpoch === undefined) { + return noOp('epoch provider unreadable'); + } + if (currentEpoch === node.lastEpoch) { + return noOp('same epoch — slot already burned'); + } + + const gatePasses = await ctx.client.readContract({ + address: node.gate.address, + abi: priceFloorGateAbi, + functionName: 'passes', + }); + if (!gatePasses) { + return noOp('gate closed'); + } + + const budget = readBudget(node.budget, state); + if (budget.wouldRevert) { + return noOp(`budget would revert: ${budget.wouldRevert.reason}`, { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }); + } + if (budget.amount === 0n) { + return noOp('budget amount is zero', { + amount: 0n, + token: budget.token, + provenance: budget.provenance, + }); + } + + // CowSwap approve-only + presign at dispatch time: no balance change. + // The buy-side amount only lands on a solver fill (off-chain), which + // this preview can't model. Estimate the minimum buy amount for the + // UI so the user can compare against the post-fill state. + let minBuy: { + amount: bigint; + provenance: 'estimated-via-oracle' | 'opaque'; + } = { + amount: 0n, + provenance: 'opaque', + }; + try { + const [price] = await ctx.client.readContract({ + address: node.priceOracle, + abi: oraclePriceAbi, + functionName: 'getPrice', + args: [budget.token.address, node.targetToken.address], + }); + const expected = (budget.amount * price) / PRICE_SCALE; + const slippage = node.maxSlippageBps; + const minBuyAmount = + (expected * (SLIPPAGE_BASE - slippage)) / SLIPPAGE_BASE; + minBuy = { + amount: minBuyAmount, + provenance: 'estimated-via-oracle', + }; + } catch { + // Leave minBuy as opaque + } + + return { + status: 'executed', + budget: { + amount: budget.amount, + token: budget.token, + provenance: budget.provenance, + }, + transfers: [], + externalCalls: [ + { + to: node.cowSwapSettlement, + description: `presign sell ${budget.amount} ${budget.token.symbol ?? '?'} → ≥${ + minBuy.amount + } ${node.targetToken.symbol ?? '?'} (off-chain fill)`, + consumes: [], + // The buyback lands back in the DAO when the solver fills. + // We can estimate the minimum buy via the oracle, so the + // canvas shows the buyback returning (estimated) rather than + // a one-way sell arrow. + produces: + minBuy.amount > 0n + ? [ + { + token: node.targetToken, + amount: minBuy.amount, + provenance: minBuy.provenance, + }, + ] + : [], + }, + ], + notes: [ + 'approve + setPreSignature: no balance change at dispatch time', + 'solver fill is async — actual buy-side delta only lands when settled', + ], + }; + }, +}); + +// --- Helpers --------------------------------------------------------------- + +function skipPaused(): PredictResult { + return { + status: 'skipped-paused', + reason: 'strategy.paused() === true', + transfers: [], + externalCalls: [], + notes: [], + }; +} + +function noOp(reason: string, budget?: PredictResult['budget']): PredictResult { + return { + status: 'no-op', + reason, + ...(budget === undefined ? {} : { budget }), + transfers: [], + externalCalls: [], + notes: [], + }; +} + +/** Build Transfer records from Solo/Equal/Ratio allocations. */ +function buildTransfers( + dao: `0x${string}`, + node: Extract< + StrategyNode, + { + kind: + | 'strategy.dispatch.transfer' + | 'strategy.dispatch.epoch-transfer'; + } + >, + allocations: ReturnType['allocations'], +): Transfer[] { + return allocations + .filter((a) => a.amount > 0n) + .map((a) => ({ + from: dao, + to: a.recipient, + token: node.budget.token, + amount: a.amount, + provenance: a.provenance, + })); +} diff --git a/src/shared/lidoPreview/types/flow.ts b/src/shared/lidoPreview/types/flow.ts new file mode 100644 index 0000000000..07d5a24c85 --- /dev/null +++ b/src/shared/lidoPreview/types/flow.ts @@ -0,0 +1,95 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import type { Provenance, TokenInfo } from './primitives'; +import type { NodeKind } from './topology'; + +/** + * Stepped simulation of a dispatch call against a TopologyGraph at a given + * block. Each Step is self-contained (carries before/after snapshots) so a + * UI can scrub through them without re-running the simulator. + */ +export type FlowGraph = { + version: 1; + chainId: number; + simulatedAtBlock: bigint; + simulatedAt: string; // ISO 8601 + plugin: Address; + initialState: VaultSnapshot; + steps: Step[]; + finalState: VaultSnapshot; + warnings: FlowWarning[]; +}; + +export type FlowWarning = { + code: string; + message: string; + stepIndex?: number; +}; + +export type VaultSnapshot = { + dao: Address; + balances: TokenBalance[]; + /** Present when any strategy consults an epoch provider. */ + epoch?: bigint; +}; + +export type TokenBalance = { + token: TokenInfo; + amount: bigint; + provenance: Provenance; +}; + +export type Step = { + index: number; + strategyIndex: number; + strategyRef: { address: Address; kind: NodeKind }; + status: StepStatus; + /** Human-readable note explaining non-`executed` statuses. */ + reason?: string; + budget?: { + amount: bigint; + token: TokenInfo; + provenance: Provenance; + }; + transfers: Transfer[]; + externalCalls: ExternalCall[]; + before: VaultSnapshot; + after: VaultSnapshot; + notes: string[]; +}; + +export type StepStatus = + | 'executed' // Transfer happened or burn occurred as modelled. + | 'no-op' // Strategy ran but produced no actions (e.g. already dispatched this epoch). + | 'skipped-paused' // Strategy.paused() === true. + | 'opaque' // Unknown strategy — could not predict output. + | 'downstream-opaque'; // Known strategy executed after an opaque one. + +export type Transfer = { + from: Address; + to: Address; + token: TokenInfo; + amount: bigint; + provenance: Provenance; +}; + +export type ExternalCall = { + to: Address; + description: string; + value?: bigint; + /** + * Token deltas this call causes on the vault. Used by the simulator to + * propagate effects into the next step's VaultSnapshot. + */ + produces: TokenDelta[]; + consumes: TokenDelta[]; +}; + +export type TokenDelta = { + token: TokenInfo; + amount: bigint; + provenance: Provenance; +}; diff --git a/src/shared/lidoPreview/types/index.ts b/src/shared/lidoPreview/types/index.ts new file mode 100644 index 0000000000..0cb352e0d8 --- /dev/null +++ b/src/shared/lidoPreview/types/index.ts @@ -0,0 +1,8 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +export * from './flow'; +export * from './json'; +export * from './primitives'; +export * from './topology'; diff --git a/src/shared/lidoPreview/types/json.ts b/src/shared/lidoPreview/types/json.ts new file mode 100644 index 0000000000..bfa0e1b0d5 --- /dev/null +++ b/src/shared/lidoPreview/types/json.ts @@ -0,0 +1,19 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +// JSON (de)serialization for artifacts containing bigints. +// +// Runtime types use native bigint so the simulator can do arithmetic +// directly. At JSON boundaries, bigints become decimal strings. We don't +// auto-revive them on parse — that requires a schema — consumers that want +// typed objects back should use a schema-aware parser. + +/** `JSON.stringify` replacer: encodes bigints as decimal strings. */ +export const jsonReplacer = (_key: string, value: unknown): unknown => + typeof value === 'bigint' ? value.toString() : value; + +/** Convenience around `JSON.stringify(value, jsonReplacer, space)`. */ +export function stringify(value: unknown, space: number | string = 2): string { + return JSON.stringify(value, jsonReplacer, space); +} diff --git a/src/shared/lidoPreview/types/primitives.ts b/src/shared/lidoPreview/types/primitives.ts new file mode 100644 index 0000000000..5b1789a74a --- /dev/null +++ b/src/shared/lidoPreview/types/primitives.ts @@ -0,0 +1,41 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; + +/** + * Minimal token descriptor. `null` for symbol/decimals indicates the read + * failed or the token is native ETH (`address(0)`). + */ +export type TokenInfo = { + address: Address; + symbol: string | null; + decimals: number | null; +}; + +/** + * Source of a simulated numerical value, so the UI can distinguish exact + * from estimated from unknown. + * + * - `deterministic` — pure math (transfer, burn, checkpoint). + * - `estimated-via-quoter` — on-chain quoter (Uniswap V3 QuoterV2, etc.). + * - `estimated-via-oracle` — strategy's configured price oracle. + * - `opaque` — unknown component; prediction unavailable. + * - `downstream-of-opaque` — follows an opaque step, so its inputs + * (and therefore outputs) are uncertain. + * - `override` — caller supplied the value directly. + */ +export type Provenance = + | 'deterministic' + | 'estimated-via-quoter' + | 'estimated-via-oracle' + | 'opaque' + | 'downstream-of-opaque' + | 'override'; + +/** Strip the `org.aragon.` prefix shared by every built-in *Id() string. */ +export function kindFromId(id: string): string { + const prefix = 'org.aragon.'; + return id.startsWith(prefix) ? id.slice(prefix.length) : id; +} diff --git a/src/shared/lidoPreview/types/topology.ts b/src/shared/lidoPreview/types/topology.ts new file mode 100644 index 0000000000..4b8ababe70 --- /dev/null +++ b/src/shared/lidoPreview/types/topology.ts @@ -0,0 +1,240 @@ +// Vendored from dao-launchpad@f/lido-demo:lido/preview/lib/src — do not edit by hand. +// See infra/lmm-demo/README.md → "Updating vendored libs" for the refresh procedure. +// Upstream package: @aragon/lido-preview (private). + +import type { Address } from 'viem'; +import type { TokenInfo } from './primitives'; + +/** + * Structural snapshot of a deployed Capital Router configuration. Pure JSON + * (bigints serialize as decimal strings via `stringify`). Stable between + * runs for a given block. + */ +export type TopologyGraph = { + version: 1; + chainId: number; + fetchedAtBlock: bigint; + fetchedAt: string; // ISO 8601 + root: PluginNode; + warnings: Warning[]; +}; + +export type Warning = { + code: string; + message: string; + path?: string; // dotted path into the graph, e.g. "root.strategies[0].budget" +}; + +// --- Plugins ---------------------------------------------------------------- + +export type PluginNode = DispatcherPluginNode | UnknownPluginNode; + +export type DispatcherPluginNode = { + kind: 'plugin.dispatch'; + address: Address; + pluginId: string; + dao: Address; + strategies: StrategyNode[]; + failsafeStrategyMap: bigint; +}; + +export type UnknownPluginNode = { + kind: 'plugin.unknown'; + address: Address; + pluginId: string | null; + dao: Address | null; +}; + +// --- Strategies ------------------------------------------------------------- + +export type StrategyNode = + // CR vanilla strategies (kept as catch-alls / for mixed deployments). + | TransferDispatchStrategyNode + | BurnDispatchStrategyNode + | EpochTransferDispatchStrategyNode + // Lido custom strategies (Money Machine). + | LidoWrapDispatchStrategyNode + | LidoUniV2LiquidityDispatchStrategyNode + | LidoGatedCowSwapDispatchStrategyNode + // Fallback when a strategyId is not in the registry. + | UnknownStrategyNode; + +type StrategyCommon = { + address: Address; + strategyId: string; + paused: boolean; +}; + +export type TransferDispatchStrategyNode = StrategyCommon & { + kind: 'strategy.dispatch.transfer'; + budget: BudgetNode; + splitter: SplitterNode; + useSafeTransfer: boolean; +}; + +export type BurnDispatchStrategyNode = StrategyCommon & { + kind: 'strategy.dispatch.burn'; + budget: BudgetNode; +}; + +export type EpochTransferDispatchStrategyNode = StrategyCommon & { + kind: 'strategy.dispatch.epoch-transfer'; + budget: BudgetNode; + splitter: SplitterNode; + epochProvider: EpochProviderNode; + lastDispatchedEpoch: bigint; + useSafeTransfer: boolean; +}; + +/** Wraps the DAO's stETH balance into wstETH. */ +export type LidoWrapDispatchStrategyNode = StrategyCommon & { + kind: 'strategy.dispatch.lido.wrap'; + budget: BudgetNode; + /** Target wstETH wrapper contract — `wstETH.wrap(stETH)`. */ + wstETH: Address; + /** Token info for the wstETH wrapper (symbol/decimals) — surfaced so the + * Wrap predictor can build a typed `produces` delta for the post-wrap + * snapshot without a second on-chain read. */ + wstETHToken: TokenInfo; +}; + +/** UniV2 add-liquidity from two budgets (LDO + streamed wstETH), LP to lpRecipient. */ +export type LidoUniV2LiquidityDispatchStrategyNode = StrategyCommon & { + kind: 'strategy.dispatch.lido.univ2-liquidity'; + /** Primary budget (full LDO). */ + budget: BudgetNode; + /** Secondary budget (streamed wstETH). */ + budgetB: BudgetNode; + router: Address; + oracle: Address; + epochProvider: EpochProviderNode; + lpRecipient: Address; + maxSlippageBps: number; + maxStaleness: bigint; + /** Last epoch this strategy fired (or attempted to). */ + lastEpoch: bigint; +}; + +/** CR's CowSwap strategy with a soft `PriceFloorGate` + once-per-epoch lockout. */ +export type LidoGatedCowSwapDispatchStrategyNode = StrategyCommon & { + kind: 'strategy.dispatch.lido.gated-cowswap'; + budget: BudgetNode; + targetToken: TokenInfo; + cowSwapSettlement: Address; + priceOracle: Address; + maxSlippageBps: bigint; + maxStaleness: bigint; + useSafeApproval: boolean; + gate: PriceFloorGateNode; + epochProvider: EpochProviderNode; + lastEpoch: bigint; +}; + +export type UnknownStrategyNode = StrategyCommon & { + kind: 'strategy.unknown'; + budget: BudgetNode | null; // best-effort read via IDispatchStrategy +}; + +// --- Budgets ---------------------------------------------------------------- + +export type BudgetNode = + | FullBudgetNode + | RequiredBudgetNode + | LidoStreamUntilBudgetNode + | UnknownBudgetNode; + +type BudgetCommon = { + address: Address; + budgetId: string; + token: TokenInfo; +}; + +export type FullBudgetNode = BudgetCommon & { + kind: 'budget.full'; + vault: Address; +}; + +export type RequiredBudgetNode = BudgetCommon & { + kind: 'budget.required'; + vault: Address; + requiredAmount: bigint; +}; + +/** Operator-paced stream that targets a `targetEpoch` with a `floorEpochs` anti-spike floor. */ +export type LidoStreamUntilBudgetNode = BudgetCommon & { + kind: 'budget.lido.stream-until'; + vault: Address; + epochProvider: EpochProviderNode; + targetEpoch: bigint; + floorEpochs: number; +}; + +export type UnknownBudgetNode = BudgetCommon & { + kind: 'budget.unknown'; +}; + +// --- Splitters -------------------------------------------------------------- + +export type SplitterNode = + | SoloSplitterNode + | EqualSplitterNode + | RatioSplitterNode + | UnknownSplitterNode; + +type SplitterCommon = { + address: Address; + splitterId: string; +}; + +export type SoloSplitterNode = SplitterCommon & { + kind: 'splitter.solo'; + recipient: Address; +}; + +export type EqualSplitterNode = SplitterCommon & { + kind: 'splitter.equal'; + recipients: Address[]; +}; + +export type RatioSplitterNode = SplitterCommon & { + kind: 'splitter.ratio'; + /** Ratios are in parts-per-million (RATIO_BASE = 1_000_000). */ + entries: { recipient: Address; ratio: number }[]; +}; + +export type UnknownSplitterNode = SplitterCommon & { + kind: 'splitter.unknown'; +}; + +// --- Gate (Lido-specific auxiliary node, not a CR splitter / budget) ------- + +export type PriceFloorGateNode = { + kind: 'lido.price-floor-gate'; + address: Address; + vault: Address; + oracle: Address; + tokenA: Address; + tokenB: Address; + threshold: bigint; + maxStaleness: bigint; +}; + +// --- Epoch provider --------------------------------------------------------- + +export type EpochProviderNode = { + kind: 'epoch-provider'; + address: Address; + currentEpoch: bigint; +}; + +// --- Union of any node kind (for registry keying, rendering) ---------------- + +export type AnyNode = + | PluginNode + | StrategyNode + | BudgetNode + | SplitterNode + | EpochProviderNode + | PriceFloorGateNode; + +export type NodeKind = AnyNode['kind']; diff --git a/tsconfig.json b/tsconfig.json index 3c5425e4a2..13b0087a52 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,7 +39,7 @@ "skipLibCheck": true, "strict": true, "strictNullChecks": true, - "target": "ES2017" + "target": "ES2020" }, "exclude": [ "node_modules",