From e18ab7f210ab0f0f17e84676210945948851e193 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 23 Apr 2026 23:05:14 +0200 Subject: [PATCH 01/13] feat: Implement Flow module with new components and pages - Added FlowLayout, FlowActivityFeed, FlowKpiRow, and FlowLede components for enhanced flow management. - Introduced FlowPoliciesSection and FlowOrchestratorsSection to organize policies and orchestrators. - Created new pages for flow activities, policies, and recipients. - Updated .env.local to include configuration for the flow indexer. - Enhanced dispatch dialogs to support new flow functionalities. This commit lays the groundwork for the Flow feature set, improving user experience and functionality in managing flow-related operations. --- config/.env.local | 4 + .../[addressOrEns]/flow/activity/page.tsx | 3 + .../[network]/[addressOrEns]/flow/layout.tsx | 29 + .../[network]/[addressOrEns]/flow/page.tsx | 3 + .../flow/policies/[id]/page.tsx | 3 + .../[addressOrEns]/flow/recipients/page.tsx | 3 + .../components/providers/providersDialogs.ts | 2 + .../dispatchSimulationDialog.tsx | 9 + .../dispatchTransactionDialog.tsx | 23 +- .../flowActivityFeed/flowActivityFeed.tsx | 347 +++++ .../flow/components/flowKpiRow/flowKpiRow.tsx | 186 +++ .../flow/components/flowLayout/flowLayout.tsx | 37 + .../flow/components/flowLede/flowLede.tsx | 93 ++ .../flowMultiDispatchCard.tsx | 364 +++++ .../flowOrchestratorsSection.tsx | 54 + .../components/flowOrchestrators/index.ts | 2 + .../flowPoliciesSection.tsx | 143 ++ .../components/flowPoliciesSection/index.ts | 1 + .../flowPolicyCard/flowPolicyCard.tsx | 434 ++++++ .../flowPolicyChart/flowEventStyles.ts | 62 + .../flowPolicyChart/flowPolicyChart.tsx | 502 +++++++ .../flowPolicyHistory/flowPolicyHistory.tsx | 277 ++++ .../flowPolicyStructure.tsx | 208 +++ .../flowPolicyStructure/flowPolicyTree.tsx | 312 +++++ .../flowPrimitives/flowAddressLabel.tsx | 144 ++ .../flowPrimitives/flowBlockieAvatar.tsx | 76 ++ .../flowPrimitives/flowStatusDot.tsx | 60 + .../flowPrimitives/flowStrategyChip.tsx | 21 + .../flowPrimitives/flowTokenChip.tsx | 27 + .../flowWaitingForIndexerBadge.tsx | 101 ++ .../flow/components/flowPrimitives/index.ts | 12 + .../flowRecipientsTable.tsx | 324 +++++ .../flowSparkline/flowSparkline.tsx | 272 ++++ .../flow/components/flowSubNav/flowSubNav.tsx | 87 ++ .../flowToastStack/flowToastStack.tsx | 49 + .../flow/components/flowTopbar/flowTopbar.tsx | 83 ++ src/modules/flow/constants/flowDialogId.ts | 3 + .../flow/constants/flowDialogsDefinitions.ts | 13 + .../confirmDispatchDialog.tsx | 145 ++ .../dialogs/confirmDispatchDialog/index.ts | 5 + src/modules/flow/hooks/index.ts | 1 + src/modules/flow/hooks/useFlowData.ts | 20 + src/modules/flow/index.ts | 12 + src/modules/flow/mocks/mockFlowData.ts | 905 +++++++++++++ .../flowActivityPage/flowActivityPage.tsx | 17 + .../flowActivityPageClient.tsx | 35 + .../flow/pages/flowActivityPage/index.ts | 4 + .../flowOverviewPage/flowOverviewPage.tsx | 17 + .../flowOverviewPageClient.tsx | 47 + .../flow/pages/flowOverviewPage/index.ts | 4 + .../flowPolicyDetailPage.tsx | 25 + .../flowPolicyDetailPageClient.tsx | 281 ++++ .../flow/pages/flowPolicyDetailPage/index.ts | 4 + .../flowRecipientsPage/flowRecipientsPage.tsx | 20 + .../flowRecipientsPageClient.tsx | 30 + .../flow/pages/flowRecipientsPage/index.ts | 4 + .../flow/providers/flowDataProvider.tsx | 427 ++++++ .../flow/providers/useEnvioFlowData.ts | 280 ++++ src/modules/flow/types.ts | 382 ++++++ src/modules/flow/utils/envioFlowMapper.ts | 1186 +++++++++++++++++ src/modules/flow/utils/flowAddressBook.ts | 176 +++ src/modules/flow/utils/flowFormatters.ts | 95 ++ .../api/flowIndexer/flowIndexerClient.ts | 82 ++ src/shared/api/flowIndexer/flowIndexerKeys.ts | 11 + .../api/flowIndexer/flowIndexerTypes.ts | 121 ++ src/shared/api/flowIndexer/index.ts | 23 + .../queries/useFlowIndexerDaoData.ts | 159 +++ 67 files changed, 8890 insertions(+), 1 deletion(-) create mode 100644 src/app/dao/[network]/[addressOrEns]/flow/activity/page.tsx create mode 100644 src/app/dao/[network]/[addressOrEns]/flow/layout.tsx create mode 100644 src/app/dao/[network]/[addressOrEns]/flow/page.tsx create mode 100644 src/app/dao/[network]/[addressOrEns]/flow/policies/[id]/page.tsx create mode 100644 src/app/dao/[network]/[addressOrEns]/flow/recipients/page.tsx create mode 100644 src/modules/flow/components/flowActivityFeed/flowActivityFeed.tsx create mode 100644 src/modules/flow/components/flowKpiRow/flowKpiRow.tsx create mode 100644 src/modules/flow/components/flowLayout/flowLayout.tsx create mode 100644 src/modules/flow/components/flowLede/flowLede.tsx create mode 100644 src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx create mode 100644 src/modules/flow/components/flowOrchestrators/flowOrchestratorsSection.tsx create mode 100644 src/modules/flow/components/flowOrchestrators/index.ts create mode 100644 src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx create mode 100644 src/modules/flow/components/flowPoliciesSection/index.ts create mode 100644 src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx create mode 100644 src/modules/flow/components/flowPolicyChart/flowEventStyles.ts create mode 100644 src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx create mode 100644 src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx create mode 100644 src/modules/flow/components/flowPolicyStructure/flowPolicyStructure.tsx create mode 100644 src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx create mode 100644 src/modules/flow/components/flowPrimitives/flowAddressLabel.tsx create mode 100644 src/modules/flow/components/flowPrimitives/flowBlockieAvatar.tsx create mode 100644 src/modules/flow/components/flowPrimitives/flowStatusDot.tsx create mode 100644 src/modules/flow/components/flowPrimitives/flowStrategyChip.tsx create mode 100644 src/modules/flow/components/flowPrimitives/flowTokenChip.tsx create mode 100644 src/modules/flow/components/flowPrimitives/flowWaitingForIndexerBadge.tsx create mode 100644 src/modules/flow/components/flowPrimitives/index.ts create mode 100644 src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx create mode 100644 src/modules/flow/components/flowSparkline/flowSparkline.tsx create mode 100644 src/modules/flow/components/flowSubNav/flowSubNav.tsx create mode 100644 src/modules/flow/components/flowToastStack/flowToastStack.tsx create mode 100644 src/modules/flow/components/flowTopbar/flowTopbar.tsx create mode 100644 src/modules/flow/constants/flowDialogId.ts create mode 100644 src/modules/flow/constants/flowDialogsDefinitions.ts create mode 100644 src/modules/flow/dialogs/confirmDispatchDialog/confirmDispatchDialog.tsx create mode 100644 src/modules/flow/dialogs/confirmDispatchDialog/index.ts create mode 100644 src/modules/flow/hooks/index.ts create mode 100644 src/modules/flow/hooks/useFlowData.ts create mode 100644 src/modules/flow/index.ts create mode 100644 src/modules/flow/mocks/mockFlowData.ts create mode 100644 src/modules/flow/pages/flowActivityPage/flowActivityPage.tsx create mode 100644 src/modules/flow/pages/flowActivityPage/flowActivityPageClient.tsx create mode 100644 src/modules/flow/pages/flowActivityPage/index.ts create mode 100644 src/modules/flow/pages/flowOverviewPage/flowOverviewPage.tsx create mode 100644 src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx create mode 100644 src/modules/flow/pages/flowOverviewPage/index.ts create mode 100644 src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPage.tsx create mode 100644 src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx create mode 100644 src/modules/flow/pages/flowPolicyDetailPage/index.ts create mode 100644 src/modules/flow/pages/flowRecipientsPage/flowRecipientsPage.tsx create mode 100644 src/modules/flow/pages/flowRecipientsPage/flowRecipientsPageClient.tsx create mode 100644 src/modules/flow/pages/flowRecipientsPage/index.ts create mode 100644 src/modules/flow/providers/flowDataProvider.tsx create mode 100644 src/modules/flow/providers/useEnvioFlowData.ts create mode 100644 src/modules/flow/types.ts create mode 100644 src/modules/flow/utils/envioFlowMapper.ts create mode 100644 src/modules/flow/utils/flowAddressBook.ts create mode 100644 src/modules/flow/utils/flowFormatters.ts create mode 100644 src/shared/api/flowIndexer/flowIndexerClient.ts create mode 100644 src/shared/api/flowIndexer/flowIndexerKeys.ts create mode 100644 src/shared/api/flowIndexer/flowIndexerTypes.ts create mode 100644 src/shared/api/flowIndexer/index.ts create mode 100644 src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts diff --git a/config/.env.local b/config/.env.local index bfc9d9074e..3514c05487 100644 --- a/config/.env.local +++ b/config/.env.local @@ -15,3 +15,7 @@ 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 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/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/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..01ae1d2b9e 100644 --- a/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx +++ b/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx @@ -1,6 +1,6 @@ import { invariant } from '@aragon/gov-ui-kit'; 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'; import type { Network } from '@/shared/api/daoService'; import type { IDaoPolicy } from '@/shared/api/daoService/domain/daoPolicy'; @@ -31,6 +31,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 @@ -60,6 +69,7 @@ export const DispatchTransactionDialog: React.FC< network, showBackButton = false, routerSelectorParams, + onDispatchSuccess, } = location.params; const { address } = useWalletAccount(); @@ -104,12 +114,23 @@ export const DispatchTransactionDialog: React.FC< } }; + const handleReceipt = (receipt: TransactionReceipt) => { + if (onDispatchSuccess == null) { + return; + } + onDispatchSuccess({ + txHash: receipt.transactionHash, + receipt, + }); + }; + return ( = { + 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; + +const buildRecipientLabel = ( + policy: IFlowPolicy, + dispatch: IFlowPolicy['dispatches'][number], +): string => { + if (dispatch.recipientsCount === 0) { + return 'No recipients yet'; + } + 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[] = []; + for (const policy of data.policies) { + 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 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)} + + + {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)} {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..91e066f831 --- /dev/null +++ b/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx @@ -0,0 +1,186 @@ +import classNames from 'classnames'; +import { + FLOW_ACTIVE_STATUSES, + type FlowTokenSymbol, + type 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' | 'success' | 'primary'; +} + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +const toneClasses: Record, string> = { + default: 'border-neutral-100', + critical: 'border-critical-200 bg-critical-50', + success: 'border-success-200 bg-success-50', + primary: 'border-primary-200 bg-primary-50', +}; + +const deltaClasses: Record<'up' | 'down' | 'flat', string> = { + up: 'text-success-700', + down: 'text-critical-700', + flat: 'text-neutral-500', +}; + +export const FlowKpiRow: React.FC = (props) => { + const { data } = props; + const { policies } = data; + + const total = policies.length; + const active = policies.filter((p) => + FLOW_ACTIVE_STATUSES.includes(p.status), + ).length; + const paused = policies.filter((p) => p.status === 'paused').length; + const awaiting = policies.filter((p) => p.status === 'awaiting').length; + + const readyPolicies = policies.filter((p) => p.status === 'ready'); + const readyCount = readyPolicies.length; + 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 pendingSummary = + Object.entries(pendingByToken) + .map( + ([token, amount]) => + `${formatFlowAmount(amount as number, token as FlowTokenSymbol)} ${token}`, + ) + .join(' · ') || 'no queued amounts'; + + const now = Date.now(); + const dispatches7d = policies.reduce( + (sum, policy) => + sum + + policy.dispatches.filter( + (d) => + d.status !== 'failed' && + now - new Date(d.at).getTime() <= WEEK_MS, + ).length, + 0, + ); + const dispatchesPrev7d = policies.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 = policies.reduce( + (sum, policy) => + sum + + policy.dispatches.filter( + (d) => + d.status === 'failed' && + now - new Date(d.at).getTime() <= WEEK_MS, + ).length, + 0, + ); + const failedEver = policies.reduce( + (sum, policy) => + sum + policy.dispatches.filter((d) => d.status === 'failed').length, + 0, + ); + + const items: IKpiItem[] = [ + { + label: 'Active automations', + value: `${active} / ${total}`, + hint: `${paused} paused · ${awaiting} awaiting`, + }, + { + 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: 'success', + }, + ]; + + 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..d7dbd3d45c --- /dev/null +++ b/src/modules/flow/components/flowLayout/flowLayout.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; +import { FlowDataProvider } from '../../providers/flowDataProvider'; +import { FlowSubNav } from '../flowSubNav/flowSubNav'; +import { FlowToastStack } from '../flowToastStack/flowToastStack'; +import { FlowTopbar } from '../flowTopbar/flowTopbar'; + +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/flowLede/flowLede.tsx b/src/modules/flow/components/flowLede/flowLede.tsx new file mode 100644 index 0000000000..eade13ccfb --- /dev/null +++ b/src/modules/flow/components/flowLede/flowLede.tsx @@ -0,0 +1,93 @@ +import { + FLOW_ACTIVE_STATUSES, + type FlowTokenSymbol, + type 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, policies } = data; + + const readyPolicies = policies.filter((p) => p.status === 'ready'); + const activePolicies = policies.filter((p) => + FLOW_ACTIVE_STATUSES.includes(p.status), + ); + + 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 pendingSummary = Object.entries(pendingByToken) + .map( + ([token, amount]) => + `${formatFlowAmount(amount as number, token as FlowTokenSymbol)} ${token}`, + ) + .join(' + '); + + const cooldownPolicies = policies + .filter((p) => p.cooldown != null && p.status !== 'ready') + .map((p) => p.cooldown!) + .sort( + (a, b) => + new Date(a.readyAt).getTime() - new Date(b.readyAt).getTime(), + ); + const nextReady = cooldownPolicies[0]; + + const now = Date.now(); + const failed7d = policies.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'}`, + ); + } else { + narrativeParts.push( + `${activePolicies.length} active automation${activePolicies.length === 1 ? '' : 's'}`, + ); + } + if (nextReady != null) { + narrativeParts.push( + `next autonomous dispatch ${formatRelative(nextReady.readyAt)}`, + ); + } + if (failed7d > 0) { + narrativeParts.push(`${failed7d} failed last week`); + } else { + narrativeParts.push('all operational'); + } + + return ( +
+

+ {dao.name} · Flow +

+

+ {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..8f30cebae9 --- /dev/null +++ b/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx @@ -0,0 +1,364 @@ +'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 { + 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: 'border-success-200', + cooldown: 'border-warning-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}`; + + const { dispatchPolicy, getPendingDispatch } = useFlowDataContext(); + const pendingDispatch = getPendingDispatch(orchestrator.id); + const isDispatching = pendingDispatch != null; + const isArchived = orchestrator.status === 'paused'; + const hasChain = 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, + ); + // Orchestrators don't have a dedicated detail page yet — the policy detail route only + // knows about leaf policies, and the data it would render would be a subset of what's + // already on the card (name, chain diagram, runs). Surface everything inline here + // and skip the navigation to avoid a "Policy not found" dead-end. + + return ( +
+
+
+
+ +

+ {orchestrator.name} +

+
+

+ {orchestrator.description} +

+
+
+ + + Edit ↗ + +
+
+ + + +
+
+

+ 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 = () => ( + + → + +); + +// --------------------------------------------------------------------------- +// 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'; + 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..32c9506a9f --- /dev/null +++ b/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx @@ -0,0 +1,143 @@ +'use client'; + +import classNames from 'classnames'; +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +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], + ); + + const defaultPill = pills[0]?.id ?? 'active'; + const [selected, setSelected] = useState(defaultPill); + + // If the bucket the user is currently on becomes empty (e.g. dispatch moved a policy + // out of "Not yet dispatched"), fall back to the first available pill. + useEffect(() => { + if (!pills.some((p) => p.id === selected)) { + setSelected(pills[0]?.id ?? 'active'); + } + }, [pills, selected]); + + const active = pills.find((p) => p.id === selected) ?? pills[0]; + const addAutomationHref = `/dao/${network}/${addressOrEns}/settings/automations`; + + return ( +
    +
    +
    +

    + Policies +

    + {pills.length > 1 && ( +
    + {pills.map((pill) => ( + + ))} +
    + )} +
    + + + Add automation ↗ + +
    + + {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..57b42c40b2 --- /dev/null +++ b/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx @@ -0,0 +1,434 @@ +'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', + live: 'border-success-200', + cooldown: 'border-warning-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; + + // Archived policies never dispatch again — show an uninstall chip instead. + if (policy.status === 'paused') { + const uninstalledLabel = policy.uninstalledAt + ? `Archived · uninstalled ${formatRelative(policy.uninstalledAt)}` + : '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() > Date.now(); + + 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: 'success' | 'warning' | 'neutral'; + children: React.ReactNode; +}> = ({ tone, children }) => { + const toneClass = { + success: 'bg-success-100 text-success-800', + 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..412791f5a5 --- /dev/null +++ b/src/modules/flow/components/flowPolicyChart/flowEventStyles.ts @@ -0,0 +1,62 @@ +import type { FlowEventKind } from '../../types'; + +export const FLOW_FAILED_COLOR = '#dc2626'; + +/** + * 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 = { + policyInstalled: '#10b981', + policyUninstalled: '#991b1b', + paused: '#f59e0b', + resumed: '#84cc16', + settingsUpdated: '#6366f1', + proposalApplied: '#a855f7', + recipientsUpdated: '#ec4899', + dispatchFailed: FLOW_FAILED_COLOR, +}; + +export type FlowMarkerKind = 'dispatchOk' | 'dispatchFailed' | 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; + } + 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..4e56729a91 --- /dev/null +++ b/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx @@ -0,0 +1,502 @@ +'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, + 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(); + if (dispatch.status !== 'failed') { + cumulative += dispatch.amount; + } + points.push({ timestamp: ts, historic: cumulative }); + } + + const historicEnd = cumulative; + points.push({ timestamp: endTs, historic: historicEnd }); + + const dispatchMarkers: IFlowTimelineMarker[] = historicSorted.map( + (dispatch) => ({ + id: `dispatch-${dispatch.id}`, + timestamp: new Date(dispatch.at).getTime(), + kind: + dispatch.status === 'failed' + ? 'dispatchFailed' + : 'dispatchOk', + label: + dispatch.status === 'failed' + ? 'Failed dispatch' + : 'Dispatch', + detail: `${formatFlowAmount(dispatch.amount, dispatch.token)} ${dispatch.token} · ${formatShortDate(dispatch.at)}${ + dispatch.status === 'failed' && dispatch.failureReason + ? ` · ${dispatch.failureReason}` + : '' + }`, + }), + ); + + 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 hasOkDispatch = policy.dispatches.some((d) => d.status !== 'failed'); + + return ( +
    +
    +
    + + Cumulative {policy.verb} + + + {formatFlowAmount(cumulativeTotal, policy.token)}{' '} + + {policy.token} + + +
    +
    + + + {( + [ + 'policyInstalled', + 'policyUninstalled', + 'settingsUpdated', + 'recipientsUpdated', + 'paused', + 'resumed', + 'proposalApplied', + ] as FlowEventKind[] + ).map((kind) => ( + + ))} +
    +
    + +
    + + + + + + + + + + + + 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 (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..c59dbe4a77 --- /dev/null +++ b/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx @@ -0,0 +1,277 @@ +'use client'; + +import classNames from 'classnames'; +import { useMemo, useState } from 'react'; +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', + resumed: 'bg-success-100 text-success-800', + 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', +}; + +const buildRows = (policy: IFlowPolicy): IHistoryRow[] => { + const rows: IHistoryRow[] = []; + for (const dispatch of policy.dispatches) { + const isFailed = dispatch.status === 'failed'; + rows.push({ + id: `d-${dispatch.id}`, + kind: 'dispatch', + at: dispatch.at, + timestamp: new Date(dispatch.at).getTime(), + title: isFailed + ? 'Dispatch failed' + : `${policy.verb.charAt(0).toUpperCase()}${policy.verb.slice(1)}`, + description: isFailed + ? (dispatch.failureReason ?? 'Dispatch reverted.') + : dispatch.recipientsCount === 0 + ? '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 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)} + + +
      + + {row.title} + + {row.description != null && ( + + {row.description} + + )} +
      + {row.amount != null && row.tokenSymbol != null && ( + + + {formatFlowAmount( + row.amount, + row.tokenSymbol, + )}{' '} + {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..f4b8a0fb0b --- /dev/null +++ b/src/modules/flow/components/flowPolicyStructure/flowPolicyStructure.tsx @@ -0,0 +1,208 @@ +import classNames from 'classnames'; +import type { IFlowPolicy, IFlowPolicySubRouter } from '../../types'; +import { + FlowAddressLabel, + FlowBlockieAvatar, + FlowTokenChip, +} from '../flowPrimitives'; +import { FlowPolicyTree } from './flowPolicyTree'; + +export interface IFlowPolicyStructureProps { + policy: IFlowPolicy; + className?: string; +} + +export const FlowPolicyStructure: React.FC = ( + props, +) => { + const { policy, className } = props; + + if ( + policy.strategy !== 'Multi-dispatch' || + policy.schema.subRouters == null + ) { + return null; + } + + 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..49fdeadc89 --- /dev/null +++ b/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx @@ -0,0 +1,312 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import type { + IFlowPolicy, + IFlowPolicySubRouter, + IFlowRecipient, +} from '../../types'; + +export interface IFlowPolicyTreeProps { + policy: IFlowPolicy; + 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, className } = props; + + 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 ( +
    +
    +

    + Flow 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..07b41b717b --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/flowStatusDot.tsx @@ -0,0 +1,60 @@ +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: 'bg-success-500', + cooldown: 'bg-warning-400', + 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..c602d0723d --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/flowStrategyChip.tsx @@ -0,0 +1,21 @@ +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; + 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..410cd8a42f --- /dev/null +++ b/src/modules/flow/components/flowPrimitives/flowTokenChip.tsx @@ -0,0 +1,27 @@ +import classNames from 'classnames'; +import { FLOW_TOKENS, type FlowTokenSymbol } from '../../types'; + +export interface IFlowTokenChipProps { + token: FlowTokenSymbol; + className?: string; +} + +export const FlowTokenChip: React.FC = (props) => { + const { token, className } = props; + const meta = FLOW_TOKENS[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..7491b627fe --- /dev/null +++ b/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx @@ -0,0 +1,324 @@ +'use client'; + +import classNames from 'classnames'; +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +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(' · '); +}; + +export const FlowRecipientsTable: React.FC = ( + props, +) => { + const { data, variant = 'full', limit, policy, className } = props; + + const allRows = useMemo(() => { + if (variant === 'policy' && policy != null) { + return buildPolicyRows(policy); + } + return buildGlobalRows(data); + }, [data, variant, policy]); + + 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 = + 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' ? ( + <> + + + + + + ) : ( + <> + + + + + + )} + + + + {rows.map((row) => ( + + + {variant === 'policy' ? ( + <> + + + + + + ) : ( + <> + + + + + + )} + + ))} + {rows.length === 0 && ( + + + + )} + +
    Recipient + Total received + + Last received + + # dispatches + + Share + + From policies + + Per-token totals + + Last received + + # dispatches +
    +
    + + +
    +
    + {formatAmounts(row.amountsByToken)} + + {row.lastReceivedAt != null + ? formatRelative( + row.lastReceivedAt, + ) + : '—'} + + {row.dispatchCount ?? 0} + + {row.ratio ?? + (row.pct != null + ? `${row.pct}%` + : '—')} + + {row.fromPolicies.join(', ')} + + {formatAmounts(row.amountsByToken)} + + {row.lastReceivedAt != null + ? formatRelative( + row.lastReceivedAt, + ) + : '—'} + + {row.dispatchCount ?? '—'} +
    + No recipients match the current filters. +
    +
    +
    + ); +}; diff --git a/src/modules/flow/components/flowSparkline/flowSparkline.tsx b/src/modules/flow/components/flowSparkline/flowSparkline.tsx new file mode 100644 index 0000000000..3f74e20e3c --- /dev/null +++ b/src/modules/flow/components/flowSparkline/flowSparkline.tsx @@ -0,0 +1,272 @@ +'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 ( +
    +
    + + + + + + + + + + + + + +
    +
    + + + + + { + 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..c6de642b7a --- /dev/null +++ b/src/modules/flow/components/flowSubNav/flowSubNav.tsx @@ -0,0 +1,87 @@ +'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; +} + +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`; + + 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..befd50d2b5 --- /dev/null +++ b/src/modules/flow/components/flowTopbar/flowTopbar.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { DaoAvatar, Wallet } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import Link from 'next/link'; +import { useConnection } from 'wagmi'; +import { ApplicationDialogId } from '@/modules/application/constants/applicationDialogId'; +import { useDao } from '@/shared/api/daoService'; +import { useDialogContext } from '@/shared/components/dialogProvider'; +import { useIsMounted } from '@/shared/hooks/useIsMounted'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { ipfsUtils } from '@/shared/utils/ipfsUtils'; + +export interface IFlowTopbarProps { + daoId: string; + network: string; + addressOrEns: string; + className?: string; +} + +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 ( +
    +
    +
    + + + F + + + Flow + + + / + + + + {daoDisplayName || addressOrEns} + + +
    +
    + +
    +
    +
    + ); +}; 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/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..955e2f6fa8 --- /dev/null +++ b/src/modules/flow/hooks/index.ts @@ -0,0 +1 @@ +export { type IUseFlowDataParams, useFlowData } from './useFlowData'; diff --git a/src/modules/flow/hooks/useFlowData.ts b/src/modules/flow/hooks/useFlowData.ts new file mode 100644 index 0000000000..0fa0cdec9d --- /dev/null +++ b/src/modules/flow/hooks/useFlowData.ts @@ -0,0 +1,20 @@ +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; +} + +/** + * Reads the current Flow data snapshot from the nearest {@link FlowDataProvider}. + * Components that need to trigger mutations (e.g. "Dispatch now") should + * instead use {@link useFlowDataContext} directly. + */ +export const useFlowData = (_params: IUseFlowDataParams): IFlowDaoData => + useFlowDataContext().data; diff --git a/src/modules/flow/index.ts b/src/modules/flow/index.ts new file mode 100644 index 0000000000..f946627671 --- /dev/null +++ b/src/modules/flow/index.ts @@ -0,0 +1,12 @@ +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 { + FlowDataProvider, + useFlowDataContext, +} from './providers/flowDataProvider'; +export * from './types'; diff --git a/src/modules/flow/mocks/mockFlowData.ts b/src/modules/flow/mocks/mockFlowData.ts new file mode 100644 index 0000000000..e5e9e26108 --- /dev/null +++ b/src/modules/flow/mocks/mockFlowData.ts @@ -0,0 +1,905 @@ +import type { + FlowTokenSymbol, + IFlowDao, + IFlowDaoData, + IFlowDispatch, + IFlowEvent, + IFlowPolicy, + IFlowPolicySubRouter, + IFlowRecipient, + IFlowRecipientAggregate, +} from '../types'; +import { groupPolicies } from '../utils/envioFlowMapper'; + +const DAY = 24 * 60 * 60 * 1000; + +const now = Date.UTC(2026, 3, 22, 15, 0, 0); + +const daysAgo = (d: number): string => new Date(now - d * DAY).toISOString(); + +const hoursAgo = (h: number): string => + new Date(now - h * 60 * 60 * 1000).toISOString(); + +const daysFromNow = (d: number): string => + new Date(now + d * DAY).toISOString(); + +const makeSeededRng = (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 makeTxHash = (rand: () => number): string => + `0x${Math.floor(rand() * 1e16) + .toString(16) + .padStart(12, '0') + .slice(0, 12)}…`; + +const generateDispatches = (params: { + policyId: string; + count: number; + token: FlowTokenSymbol; + amountRange: [number, number]; + topRecipients: IFlowRecipient[]; + spanDays: number; + startDaysAgo: number; +}): IFlowDispatch[] => { + const { + policyId, + count, + token, + amountRange, + topRecipients, + spanDays, + startDaysAgo, + } = params; + const rand = makeSeededRng(`dispatch:${policyId}`); + const items: IFlowDispatch[] = []; + for (let i = 0; i < count; i += 1) { + const progress = (i + 0.5) / count; + const daysBack = startDaysAgo - progress * spanDays; + const amount = + amountRange[0] + rand() * (amountRange[1] - amountRange[0]); + items.push({ + id: `${policyId}-d-${i}`, + at: daysAgo(daysBack), + amount, + token, + recipientsCount: + 1 + Math.floor(rand() * Math.max(topRecipients.length, 5)), + topRecipients, + txHash: makeTxHash(rand), + }); + } + return items; +}; + +const payrollRecipients: IFlowRecipient[] = [ + { name: 'alice.eth', pct: 14, address: '0xa11ce…b0b' }, + { + name: 'core-team.money-machine.eth', + pct: 12, + address: '0xc01e…team', + }, + { name: 'mallory.eth', pct: 10, address: '0xma11…0ry' }, +]; + +const payrollFullRecipients: IFlowRecipient[] = [ + { ratio: '14%', name: 'alice.eth', address: '0xa11ce…b0b' }, + { + ratio: '12%', + name: 'core-team.money-machine.eth', + address: '0xc01e…team', + }, + { ratio: '10%', name: 'mallory.eth', address: '0xma11…0ry' }, + { ratio: '9%', name: 'quinn.eth', address: '0xqu11n…7e2d' }, + { ratio: '9%', name: 'nina.eth', address: '0xn1na…a4c0' }, + { ratio: '8%', name: 'yoko.eth', address: '0xyok0…ff11' }, + { ratio: '8%', name: 'levi.eth', address: '0x1ev1…2b98' }, + { ratio: '7%', name: 'uma.eth', address: '0xuma1…19aa' }, + { ratio: '7%', name: 'penn.eth', address: '0xpenn…dd33' }, + { ratio: '6%', name: '0xf3…a12c', address: '0xf3b2…a12c' }, + { ratio: '5%', name: '0x2a…9901', address: '0x2a1d…9901' }, + { ratio: '5%', name: '0x41…c0de', address: '0x41ef…c0de' }, +]; + +const lpRecipients: IFlowRecipient[] = [ + { name: 'MERC/USDC · Uni v3', pct: 38, address: '0xpool…u3-1' }, + { name: 'MERC/WETH · Uni v3', pct: 29, address: '0xpool…u3-2' }, + { name: 'MERC/DAI · Curve', pct: 18, address: '0xpool…curv' }, +]; + +const lpFullRecipients: IFlowRecipient[] = [ + { ratio: '38%', name: 'MERC/USDC · Uni v3', address: '0xp001…u3-1' }, + { ratio: '29%', name: 'MERC/WETH · Uni v3', address: '0xp002…u3-2' }, + { ratio: '18%', name: 'MERC/DAI · Curve', address: '0xp003…curv' }, + { ratio: '10%', name: 'MERC/USDT · Bal', address: '0xp004…ba11' }, + { ratio: '5%', name: 'MERC/stETH · Curve', address: '0xp005…steh' }, +]; + +const burnRecipients: IFlowRecipient[] = [ + { name: 'Burn address', pct: 100, address: '0x0000…dead' }, +]; + +const sweepRecipients: IFlowRecipient[] = [ + { name: 'Treasury · USDC vault', pct: 100, address: '0xc0w5…wa9p' }, +]; + +const grantsRecipients: IFlowRecipient[] = [ + { name: 'optics-labs.eth', pct: 26, address: '0xop71…lab5' }, + { name: 'spellbook.eth', pct: 22, address: '0x5pe11…b00k' }, + { name: '0x9a…42be', pct: 18, address: '0x9a5c…42be' }, +]; + +const grantsFullRecipients: IFlowRecipient[] = [ + { ratio: '26%', name: 'optics-labs.eth', address: '0xop71…lab5' }, + { ratio: '22%', name: 'spellbook.eth', address: '0x5pe11…b00k' }, + { ratio: '18%', name: '0x9a…42be', address: '0x9a5c…42be' }, + { ratio: '12%', name: 'research-dao.eth', address: '0xr35e…a4c0' }, + { ratio: '10%', name: 'bluehat.eth', address: '0xb1ue…ha70' }, + { ratio: '8%', name: 'darknet.eth', address: '0xda6k…ne71' }, + { ratio: '4%', name: '0x17…beef', address: '0x1742…beef' }, +]; + +const grantsStreamRecipients: IFlowRecipient[] = [ + { name: 'research-dao.eth', pct: 40, address: '0xr35e…a4c0' }, + { name: 'bluehat.eth', pct: 35, address: '0xb1ue…ha70' }, + { name: 'optics-labs.eth', pct: 25, address: '0xop71…lab5' }, +]; + +const grantsStreamFullRecipients: IFlowRecipient[] = [ + { ratio: '40%', name: 'research-dao.eth', address: '0xr35e…a4c0' }, + { ratio: '35%', name: 'bluehat.eth', address: '0xb1ue…ha70' }, + { ratio: '25%', name: 'optics-labs.eth', address: '0xop71…lab5' }, +]; + +const multiRecipients: IFlowRecipient[] = [ + { + name: 'Contributor payroll · sub', + pct: 60, + address: 'policy:payroll', + }, + { name: 'LP incentives · sub', pct: 40, address: 'policy:lp' }, +]; + +const multiSubRouters: IFlowPolicySubRouter[] = [ + { + id: 'multi-sub-a', + title: 'Buyback Engine', + subtitle: 'sub-policy A · 55%', + allowance: { + type: 'Swap allowance', + detail: '55% of inbound · Uniswap v4', + }, + model: { + type: 'Burn router', + detail: 'Swap USDC → MERC → burn', + }, + recipients: [ + { ratio: '100%', name: 'Burn MERC', address: '0x0000…dead' }, + ], + }, + { + id: 'multi-sub-b', + title: 'LP + Contributor', + subtitle: 'sub-policy B · 30%', + allowance: { + type: 'Swap allowance', + detail: '30% of inbound · CoW Swap', + }, + model: { + type: 'Ratio splitter', + detail: 'Swap USDC → WETH, split into 3 legs', + }, + recipients: [ + { ratio: '45%', name: 'LP 0.3% Pool', address: '0xp003…lp03' }, + { ratio: '40%', name: 'Contributor Safe', address: '0xc017…safe' }, + { ratio: '15%', name: 'Bug Bounty', address: '0xbu6b…0unt' }, + ], + subRouters: [ + { + id: 'multi-sub-b-nested', + title: 'Contributor Safe · stream', + subtitle: 'nested · 40%', + allowance: { + type: 'Stream', + detail: '40% of sub-policy B per epoch', + }, + model: { + type: 'Ratio splitter', + detail: '3 contributors, voter-weighted', + }, + recipients: [ + { ratio: '50%', name: 'alice.eth', address: '0xa11ce…b0b' }, + { + ratio: '30%', + name: 'core-team.money-machine.eth', + address: '0xc01e…team', + }, + { + ratio: '20%', + name: 'mallory.eth', + address: '0xma11…0ry', + }, + ], + }, + ], + }, + { + id: 'multi-sub-c', + title: 'Ops Multisig', + subtitle: 'sub-policy C · 15%', + allowance: { + type: 'Transfer', + detail: '15% of inbound · direct transfer', + }, + model: { + type: 'Solo recipient', + detail: '100% to ops multisig', + }, + recipients: [ + { ratio: '100%', name: 'Ops Multisig', address: '0x0p5a…c0de' }, + ], + }, +]; + +const buildPayrollPolicy = (): Omit => ({ + id: 'payroll', + name: 'Contributor payroll', + description: 'Streams weekly USDC to 12 contributors via a ratio splitter.', + strategy: 'Stream', + strategyLong: 'Stream · weekly epoch', + token: 'USDC', + status: 'live', + statusLabel: 'Streaming · next in 2d', + verb: 'streamed', + createdAt: daysAgo(120), + installedViaProposal: '#42', + installedViaProposalSlug: 'proposal-42', + installTxHash: '0xinst…0042', + totalDistributed: 48_900, + forecast30d: 12_800, + nextDispatchLabel: 'Streams weekly · next in 2d', + nextDispatchAt: daysFromNow(2), + pending: null, + cooldown: { readyAt: daysFromNow(2), totalMs: 7 * DAY }, + lastDispatch: { + amount: 3100, + token: 'USDC', + at: hoursAgo(2), + txHash: '0x9aef…22c1', + recipientsCount: 12, + }, + recipients: payrollRecipients, + recipientsMore: 9, + recipientGroup: 'Contributors (12)', + dispatches: generateDispatches({ + policyId: 'payroll', + count: 16, + token: 'USDC', + amountRange: [2800, 3200], + topRecipients: payrollRecipients, + spanDays: 112, + startDaysAgo: 112, + }), + events: [ + { + id: 'payroll-installed', + kind: 'policyInstalled', + at: daysAgo(120), + title: 'Policy installed', + description: 'Contributor payroll deployed via proposal #42.', + proposalId: '#42', + proposalSlug: 'proposal-42', + txHash: '0xinst…0042', + }, + { + id: 'payroll-recipients-updated', + kind: 'recipientsUpdated', + at: daysAgo(70), + title: 'Recipients updated', + description: '+2 contributors added (quinn.eth, nina.eth).', + proposalId: '#48', + proposalSlug: 'proposal-48', + }, + { + id: 'payroll-settings-updated', + kind: 'settingsUpdated', + at: daysAgo(28), + title: 'Settings updated', + description: 'Epoch length changed from 14d to 7d.', + proposalId: '#57', + proposalSlug: 'proposal-57', + }, + ], + schema: { + source: 'Treasury vault · 0x9fA7…2e01', + allowance: { + type: 'Stream', + detail: '3,100 USDC per epoch · every 7 days', + }, + model: { + type: 'Ratio splitter', + detail: '12 recipients with individual weights', + }, + recipients: payrollFullRecipients, + }, +}); + +const buildLpPolicy = (): Omit => ({ + id: 'lp', + name: 'LP incentives', + description: 'Gauge-weighted MERC incentives to 5 LP farms every 7 days.', + strategy: 'Epoch transfer', + strategyLong: 'Epoch transfer · gauge-weighted', + token: 'MERC', + status: 'ready', + statusLabel: 'Ready · 50,000 MERC queued', + verb: 'distributed', + createdAt: daysAgo(96), + installedViaProposal: '#47', + installedViaProposalSlug: 'proposal-47', + installTxHash: '0xinst…0047', + totalDistributed: 820_000, + forecast30d: 210_000, + nextDispatchLabel: 'Ready to dispatch', + nextDispatchAt: hoursAgo(4), + pending: { amount: 50_000, token: 'MERC' }, + cooldown: null, + lastDispatch: { + amount: 50_000, + token: 'MERC', + at: daysAgo(7), + txHash: '0xd4ad…0a11', + recipientsCount: 5, + }, + recipients: lpRecipients, + recipientsMore: 2, + recipientGroup: 'LP farms', + dispatches: generateDispatches({ + policyId: 'lp', + count: 13, + token: 'MERC', + amountRange: [45_000, 52_000], + topRecipients: lpRecipients, + spanDays: 90, + startDaysAgo: 97, + }), + events: [ + { + id: 'lp-installed', + kind: 'policyInstalled', + at: daysAgo(96), + title: 'Policy installed', + description: 'LP incentives deployed via proposal #47.', + proposalId: '#47', + proposalSlug: 'proposal-47', + }, + { + id: 'lp-proposal-applied', + kind: 'proposalApplied', + at: daysAgo(40), + title: 'Proposal applied', + description: 'Gauge weights updated by proposal #52.', + proposalId: '#52', + proposalSlug: 'proposal-52', + }, + ], + schema: { + source: 'Treasury vault · 0x9fA7…2e01', + allowance: { + type: 'Fixed per epoch', + detail: '50,000 MERC per epoch · every 7 days', + }, + model: { + type: 'Gauge-weighted', + detail: 'Weighted by MERC holder votes, settled at epoch close', + }, + recipients: lpFullRecipients, + }, +}); + +const buildBurnPolicy = (): Omit => ({ + id: 'burn', + name: 'Fee burn', + description: 'Burns 100% of collected protocol fees at every epoch.', + strategy: 'Burn', + strategyLong: 'Burn router · single-token', + token: 'MERC', + status: 'cooldown', + statusLabel: 'Cooldown · ready in 4d', + verb: 'burned', + createdAt: daysAgo(80), + installedViaProposal: '#51', + installedViaProposalSlug: 'proposal-51', + installTxHash: '0xinst…0051', + totalDistributed: 445_000, + forecast30d: 115_000, + nextDispatchLabel: 'Next burn in 4d', + nextDispatchAt: daysFromNow(4), + pending: { amount: 28_000, token: 'MERC' }, + cooldown: { readyAt: daysFromNow(4), totalMs: 7 * DAY }, + lastDispatch: { + amount: 28_000, + token: 'MERC', + at: daysAgo(3), + txHash: '0x0b77…dead', + recipientsCount: 1, + }, + recipients: burnRecipients, + recipientsMore: 0, + recipientGroup: 'Burn', + dispatches: generateDispatches({ + policyId: 'burn', + count: 11, + token: 'MERC', + amountRange: [24_000, 32_000], + topRecipients: burnRecipients, + spanDays: 77, + startDaysAgo: 77, + }), + events: [ + { + id: 'burn-installed', + kind: 'policyInstalled', + at: daysAgo(80), + title: 'Policy installed', + description: 'Fee burn deployed via proposal #51.', + proposalId: '#51', + proposalSlug: 'proposal-51', + }, + ], + schema: { + source: 'Fee collector · 0xFee5…b001', + allowance: { + type: 'Drain', + detail: 'Full balance transferred at each epoch', + }, + model: { + type: 'Solo recipient', + detail: '100% to a single address', + }, + recipients: [ + { ratio: '100%', name: 'Burn address', address: '0x0000…dead' }, + ], + }, +}); + +const buildSweepPolicy = (): Omit => ({ + id: 'sweep', + name: 'Stable sweep', + description: + 'Monthly CoW-Swap converts WETH fees to USDC for the treasury.', + strategy: 'CoW swap', + strategyLong: 'CoW swap · WETH → USDC · monthly', + token: 'WETH', + status: 'cooldown', + statusLabel: 'Cooldown · window opens in 9d', + verb: 'swapped', + createdAt: daysAgo(65), + installedViaProposal: '#55', + installedViaProposalSlug: 'proposal-55', + installTxHash: '0xinst…0055', + totalDistributed: 94.1, + forecast30d: 14.0, + nextDispatchLabel: 'Claim window opens in 9d', + nextDispatchAt: daysFromNow(9), + pending: { amount: 12.4, token: 'WETH' }, + cooldown: { readyAt: daysFromNow(9), totalMs: 30 * DAY }, + lastDispatch: { + amount: 12.4, + token: 'WETH', + at: daysAgo(11), + txHash: '0xcafe…b4be', + recipientsCount: 1, + }, + recipients: sweepRecipients, + recipientsMore: 0, + recipientGroup: 'DAO vaults', + dispatches: generateDispatches({ + policyId: 'sweep', + count: 4, + token: 'WETH', + amountRange: [10, 18], + topRecipients: sweepRecipients, + spanDays: 60, + startDaysAgo: 62, + }), + events: [ + { + id: 'sweep-installed', + kind: 'policyInstalled', + at: daysAgo(65), + title: 'Policy installed', + description: 'Stable sweep deployed via proposal #55.', + proposalId: '#55', + proposalSlug: 'proposal-55', + }, + ], + schema: { + source: 'Fee collector · 0xFee5…b001', + allowance: { + type: 'Drain', + detail: 'Full WETH balance at each epoch', + }, + model: { + type: 'Solo recipient', + detail: 'Proceeds routed to treasury USDC vault', + }, + recipients: [ + { + ratio: '100%', + name: 'Treasury · USDC vault', + address: '0xc0w5…wa9p', + }, + ], + }, +}); + +const buildGrantsPolicy = (): Omit => ({ + id: 'grants', + name: 'Grants program', + description: + 'Claim-window distribution of approved grants up to 25k USDC each.', + strategy: 'Claimer', + strategyLong: 'Claimer policy · approved claims', + token: 'USDC', + status: 'live', + statusLabel: 'Claim window open · 4,180 USDC claimable', + verb: 'claimed', + createdAt: daysAgo(50), + installedViaProposal: '#61', + installedViaProposalSlug: 'proposal-61', + installTxHash: '0xinst…0061', + totalDistributed: 128_500, + forecast30d: 30_000, + nextDispatchLabel: 'Claim-driven · no scheduled dispatch', + nextDispatchAt: hoursAgo(-20), + pending: { amount: 4180, token: 'USDC' }, + cooldown: null, + failedLastDispatch: { + at: daysAgo(18), + reason: 'Slippage tolerance exceeded (0.8% vs 0.5% max).', + txHash: '0xfa11…ed18', + }, + lastDispatch: { + amount: 7300, + token: 'USDC', + at: hoursAgo(4), + txHash: '0xa1b2…14de', + recipientsCount: 1, + }, + recipients: grantsRecipients, + recipientsMore: 4, + recipientGroup: 'External EOAs', + dispatches: [ + ...generateDispatches({ + policyId: 'grants', + count: 9, + token: 'USDC', + amountRange: [3000, 9000], + topRecipients: grantsRecipients, + spanDays: 46, + startDaysAgo: 46, + }), + { + id: 'grants-d-failed-1', + at: daysAgo(18), + amount: 4800, + token: 'USDC', + recipientsCount: 1, + topRecipients: grantsRecipients, + txHash: '0xfa11…ed18', + status: 'failed', + failureReason: 'Slippage tolerance exceeded', + }, + ], + events: [ + { + id: 'grants-installed', + kind: 'policyInstalled', + at: daysAgo(50), + title: 'Policy installed', + description: 'Grants program deployed via proposal #61.', + proposalId: '#61', + proposalSlug: 'proposal-61', + }, + { + id: 'grants-dispatch-failed', + kind: 'dispatchFailed', + at: daysAgo(18), + title: 'Dispatch failed', + description: + 'Swap reverted — slippage tolerance exceeded (0.8% vs 0.5% max).', + txHash: '0xfa11…ed18', + }, + { + id: 'grants-settings-updated', + kind: 'settingsUpdated', + at: daysAgo(12), + title: 'Settings updated', + description: + 'Per-claim cap raised from 10k to 25k USDC; slippage tolerance relaxed.', + proposalId: '#67', + proposalSlug: 'proposal-67', + }, + ], + schema: { + source: 'Grants vault · 0x6ra7…c0de', + allowance: { + type: 'Pull', + detail: 'Claimants pull approved amounts in the window', + }, + model: { + type: 'Tiered', + detail: 'Per-claim cap: up to 25,000 USDC', + }, + recipients: grantsFullRecipients, + }, +}); + +const buildGrantsStreamPolicy = (): Omit => ({ + id: 'grants-stream', + name: 'Research grants stream', + description: 'Quarterly grant stream to 3 research collectives.', + strategy: 'Stream', + strategyLong: 'Stream · quarterly epoch', + token: 'USDC', + status: 'awaiting', + statusLabel: 'Awaiting proposal #71', + verb: 'streamed', + createdAt: daysAgo(12), + installedViaProposal: '#69', + installedViaProposalSlug: 'proposal-69', + installTxHash: '0xinst…0069', + totalDistributed: 0, + forecast30d: 0, + nextDispatchLabel: 'Awaiting proposal #71', + nextDispatchAt: undefined, + pending: null, + cooldown: null, + lastDispatch: undefined, + recipients: grantsStreamRecipients, + recipientsMore: 0, + recipientGroup: 'Research DAOs', + dispatches: [], + events: [ + { + id: 'grants-stream-installed', + kind: 'policyInstalled', + at: daysAgo(12), + title: 'Policy installed', + description: + 'Research grants stream scaffolded via proposal #69 — awaiting activation.', + proposalId: '#69', + proposalSlug: 'proposal-69', + }, + { + id: 'grants-stream-settings-updated', + kind: 'settingsUpdated', + at: daysAgo(4), + title: 'Settings updated', + description: + 'Quarterly cadence confirmed, awaiting activation vote in proposal #71.', + proposalId: '#71', + proposalSlug: 'proposal-71', + }, + ], + schema: { + source: 'Treasury vault · 0x9fA7…2e01', + allowance: { + type: 'Stream', + detail: '12,500 USDC per epoch · every 90 days', + }, + model: { + type: 'Ratio splitter', + detail: '3 recipients · voter-weighted', + }, + recipients: grantsStreamFullRecipients, + }, +}); + +const buildMultiPolicy = (): Omit => ({ + id: 'multi', + name: 'Cross-program distribution', + description: + 'Routes fee sweeps across buyback, LP, and contributor subpolicies.', + strategy: 'Multi-dispatch', + strategyLong: 'Multi-dispatch · chained routers', + token: 'MERC', + status: 'paused', + statusLabel: 'Paused · review pending', + verb: 'distributed', + createdAt: daysAgo(45), + installedViaProposal: '#63', + installedViaProposalSlug: 'proposal-63', + installTxHash: '0xinst…0063', + totalDistributed: 85_000, + forecast30d: 0, + nextDispatchLabel: 'Paused · review pending', + pending: null, + cooldown: null, + lastDispatch: { + amount: 42_000, + token: 'MERC', + at: daysAgo(22), + txHash: '0xmu17…21c3', + recipientsCount: 2, + }, + recipients: multiRecipients, + recipientsMore: 0, + recipientGroup: 'Sub-routers', + dispatches: generateDispatches({ + policyId: 'multi', + count: 2, + token: 'MERC', + amountRange: [40_000, 45_000], + topRecipients: multiRecipients, + spanDays: 18, + startDaysAgo: 40, + }), + events: [ + { + id: 'multi-installed', + kind: 'policyInstalled', + at: daysAgo(45), + title: 'Policy installed', + description: + 'Cross-program distribution deployed via proposal #63.', + proposalId: '#63', + proposalSlug: 'proposal-63', + }, + { + id: 'multi-paused', + kind: 'paused', + at: daysAgo(10), + title: 'Policy paused', + description: 'Paused for post-incident review.', + proposalId: '#68', + proposalSlug: 'proposal-68', + }, + ], + schema: { + source: 'Treasury vault · 0x9fA7…2e01', + allowance: { + type: 'Fixed per epoch', + detail: '100,000 MERC per epoch · every 14 days', + }, + model: { + type: 'Ratio splitter', + detail: 'Routes to nested policies', + }, + recipients: [ + { + ratio: '60%', + name: 'Contributor payroll (sub-router)', + address: 'policy:payroll', + }, + { + ratio: '40%', + name: 'LP incentives (sub-router)', + address: 'policy:lp', + }, + ], + subRouters: multiSubRouters, + }, +}); + +const buildPolicies = (): Omit[] => [ + buildPayrollPolicy(), + buildLpPolicy(), + buildBurnPolicy(), + buildSweepPolicy(), + buildGrantsPolicy(), + buildGrantsStreamPolicy(), + buildMultiPolicy(), +]; + +const buildRecipientsAggregate = ( + policies: IFlowPolicy[], +): IFlowRecipientAggregate[] => { + const map = new Map(); + for (const policy of policies) { + for (const dispatch of policy.dispatches) { + if (dispatch.status === 'failed') { + continue; + } + const top = dispatch.topRecipients[0]; + if (top == null) { + continue; + } + const key = top.address; + const existing = map.get(key); + const share = typeof top.pct === 'number' ? top.pct / 100 : 1; + const contribution = dispatch.amount * share; + if (existing) { + existing.amountsByToken[dispatch.token] = + (existing.amountsByToken[dispatch.token] ?? 0) + + contribution; + existing.dispatchCount += 1; + if ( + new Date(dispatch.at).getTime() > + new Date(existing.lastReceivedAt).getTime() + ) { + existing.lastReceivedAt = dispatch.at; + } + if (!existing.fromPolicyIds.includes(policy.id)) { + existing.fromPolicyIds.push(policy.id); + } + } else { + map.set(key, { + address: top.address, + name: top.name, + group: policy.recipientGroup, + fromPolicyIds: [policy.id], + amountsByToken: { + [dispatch.token]: contribution, + } as Partial>, + dispatchCount: 1, + lastReceivedAt: dispatch.at, + }); + } + } + } + return Array.from(map.values()).sort( + (a, b) => + new Date(b.lastReceivedAt).getTime() - + new Date(a.lastReceivedAt).getTime(), + ); +}; + +const buildDao = (network: string, addressOrEns: string): IFlowDao => ({ + network, + addressOrEns, + name: 'Money Machine DAO', + avatarColor: '#003bf5', +}); + +export const getMockFlowData = ( + network: string, + addressOrEns: string, +): IFlowDaoData => { + const rawPolicies = buildPolicies(); + // Inject synthetic `address` values so the mock satisfies `IFlowPolicy.address` (used + // by the pills grouping + external-edit links). Real Envio data carries the actual + // plugin address. + const policies = rawPolicies.map((p) => ({ + ...p, + address: p.id.startsWith('0x') + ? p.id + : (`0xmock${p.id.padEnd(36, '0')}`.slice(0, 42) as string), + })); + const recipients = buildRecipientsAggregate(policies); + return { + dao: buildDao(network, addressOrEns), + policies, + groupedPolicies: groupPolicies(policies), + orchestrators: [], + recipients, + }; +}; + +export { buildRecipientsAggregate }; + +export const getAllDispatches = (data: IFlowDaoData): IFlowDispatch[] => + data.policies + .flatMap((policy) => + policy.dispatches.map((dispatch) => ({ + ...dispatch, + policyId: policy.id, + })), + ) + .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); + +export const getAllEvents = (data: IFlowDaoData): IFlowEvent[] => + data.policies + .flatMap((policy) => policy.events) + .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); 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..5b8f68fb58 --- /dev/null +++ b/src/modules/flow/pages/flowActivityPage/flowActivityPageClient.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { FlowActivityFeed } from '../../components/flowActivityFeed/flowActivityFeed'; +import { useFlowData } from '../../hooks'; + +export interface IFlowActivityPageClientProps { + network: string; + addressOrEns: string; +} + +export const FlowActivityPageClient: React.FC = ( + props, +) => { + const { network, addressOrEns } = props; + const data = useFlowData({ network, addressOrEns }); + + 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..424cdedfb1 --- /dev/null +++ b/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx @@ -0,0 +1,47 @@ +'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 { useFlowData } from '../../hooks'; + +export interface IFlowOverviewPageClientProps { + network: string; + addressOrEns: string; +} + +export const FlowOverviewPageClient: React.FC = ( + props, +) => { + const { network, addressOrEns } = props; + const data = useFlowData({ network, addressOrEns }); + + 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..b5d8e92e3a --- /dev/null +++ b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx @@ -0,0 +1,281 @@ +'use client'; + +import { Button } from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import Link from 'next/link'; +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 { FlowDialogId } from '../../constants/flowDialogId'; +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, isEnvioLoading } = + useFlowDataContext(); + const { open } = useDialogContext(); + const decodedPolicyId = decodeURIComponent(policyId); + const policy = data.policies.find( + (p) => p.id === decodedPolicyId || p.id === policyId, + ); + const base = `/dao/${network}/${addressOrEns}/flow`; + + if (policy == null) { + if (isEnvioLoading) { + return ( +
    +
    +
    +
    +
    + ); + } + return ( +
    +

    + Policy not found +

    +

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

    + + ← Back to Flow overview + +
    + ); + } + + const handleDispatchClick = () => { + 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. + const cooldownActive = + policy.status === 'cooldown' && + policy.cooldown != null && + new Date(policy.cooldown.readyAt).getTime() > Date.now(); + 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 ( +
    +
    +
    +
    +
    +

    + {policy.name} +

    + + +
    +

    + {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 ↗ + +
    +
    +
    + {policy.status === 'paused' ? ( + + {policy.uninstalledAt + ? `Archived · uninstalled ${formatRelative(policy.uninstalledAt)}` + : 'Archived · uninstalled'} + + ) : dispatchable ? ( + <> + + + {dispatchDisabled + ? dispatchDisabledReason(policy) + : '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. + + + )} +
    +
    + {pendingDispatch != null && ( + + )} +
    + + + + + +
    + ); +}; + +const dispatchDisabledReason = (policy: IFlowPolicy): string => { + if (policy.status === 'cooldown') { + return `Cooldown · ready ${policy.cooldown != null ? formatRelative(policy.cooldown.readyAt) : ''}.`; + } + 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', + live: 'bg-success-100 text-success-800', + cooldown: 'bg-warning-100 text-warning-800', + 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..f8ad22b756 --- /dev/null +++ b/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPageClient.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { FlowRecipientsTable } from '../../components/flowRecipientsTable/flowRecipientsTable'; +import { useFlowData } from '../../hooks'; + +export interface IFlowRecipientsPageClientProps { + network: string; + addressOrEns: string; +} + +export const FlowRecipientsPageClient: React.FC< + IFlowRecipientsPageClientProps +> = (props) => { + const { network, addressOrEns } = props; + const data = useFlowData({ network, addressOrEns }); + + 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/providers/flowDataProvider.tsx b/src/modules/flow/providers/flowDataProvider.tsx new file mode 100644 index 0000000000..9aa7500d6c --- /dev/null +++ b/src/modules/flow/providers/flowDataProvider.tsx @@ -0,0 +1,427 @@ +'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'; +import { getMockFlowData } from '../mocks/mockFlowData'; +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 { + data: IFlowDaoData; + /** + * `true` while the Envio-backed snapshot is still being resolved. Consumers + * that render data-coupled UI (policy detail page, recipient detail page) + * should show a skeleton instead of "not found" until this flips to false. + * Always `false` when the Envio feature flag is off (mock-only mode). + */ + isEnvioLoading: boolean; + /** `true` once we've swapped the mock snapshot for the live Envio one. */ + hasEnvioData: 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; +} + +const FlowDataContext = createContext(null); + +export interface IFlowDataProviderProps { + network: string; + addressOrEns: string; + /** + * DAO identifier (e.g. `ethereum-sepolia-0xabc…`) — required when the + * `NEXT_PUBLIC_FLOW_USE_ENVIO` flag is on so the provider can resolve DAO + * metadata + linked accounts via the REST API. Falls back to mock data + * when the flag is off or 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, + }); + + const [data, setData] = useState(() => + getMockFlowData(network, addressOrEns), + ); + const [hasEnvioData, setHasEnvioData] = useState(false); + const [toasts, setToasts] = useState([]); + + const queryClient = useQueryClient(); + const { open } = useDialogContext(); + const { check: checkWalletConnected } = useConnectedWalletGuard(); + + useEffect(() => { + if (envioResult.enabled) { + return; + } + setData(getMockFlowData(network, addressOrEns)); + setHasEnvioData(false); + }, [envioResult.enabled, network, addressOrEns]); + + useEffect(() => { + if (!envioResult.enabled) { + return; + } + if (envioResult.data) { + setData(envioResult.data); + setHasEnvioData(true); + } + }, [envioResult.enabled, envioResult.data]); + + useEffect(() => { + if (envioResult.enabled && envioResult.isError) { + // biome-ignore lint/suspicious/noConsole: surface Envio query failures to aid debugging + console.warn( + '[FlowDataProvider] Envio query failed, keeping previous snapshot.', + envioResult.error, + ); + } + }, [envioResult.enabled, 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; + // 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; + + // `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; + } + const indexedHashes = new Set(); + for (const policy of data.policies) { + 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.policies, 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], + ); + + const isEnvioLoading = + envioResult.enabled && !hasEnvioData && !envioResult.isError; + + const value = useMemo( + () => ({ + data, + isEnvioLoading, + hasEnvioData, + dispatchPolicy, + pendingDispatches, + getPendingDispatch, + toasts, + dismissToast, + }), + [ + data, + isEnvioLoading, + hasEnvioData, + dispatchPolicy, + pendingDispatches, + getPendingDispatch, + toasts, + dismissToast, + ], + ); + + return ( + + {children} + + ); +}; + +export const useFlowDataContext = (): IFlowDataContext => { + const ctx = useContext(FlowDataContext); + if (ctx == null) { + throw new Error( + 'useFlowDataContext: must be used inside .', + ); + } + return ctx; +}; diff --git a/src/modules/flow/providers/useEnvioFlowData.ts b/src/modules/flow/providers/useEnvioFlowData.ts new file mode 100644 index 0000000000..a542660e73 --- /dev/null +++ b/src/modules/flow/providers/useEnvioFlowData.ts @@ -0,0 +1,280 @@ +/** + * 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, 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; +}; + +/** + * 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 ?? []; + 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..bf67134e12 --- /dev/null +++ b/src/modules/flow/types.ts @@ -0,0 +1,382 @@ +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. + */ + status?: 'ok' | 'failed'; + failureReason?: string; +} + +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[]; +} + +export interface IFlowOrchestratorLeg { + policyId: string; + policyName: string; + strategy: FlowPolicyStrategy; + amountOut: number; + tokenOut: FlowTokenSymbol; + amountIn?: number; + tokenIn?: FlowTokenSymbol; + recipientsCount: number; + status: 'ok' | 'failed'; +} + +/** + * 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; + runs: IFlowOrchestratorRun[]; + 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[]; + 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..b684865dbf --- /dev/null +++ b/src/modules/flow/utils/envioFlowMapper.ts @@ -0,0 +1,1186 @@ +/** + * 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 { + EnvioStrategyType, + IEnvioExecutionTransfer, + IEnvioPolicy, + IEnvioPolicyEvent, + IEnvioPolicyExecution, + IEnvioRecipientAggregate, + IFlowDaoDataResponse, +} from '@/shared/api/flowIndexer'; +import type { + FlowEventKind, + FlowPolicyStatus, + FlowPolicyStrategy, + FlowTokenSymbol, + IFlowCooldown, + IFlowDao, + IFlowDaoData, + IFlowDispatch, + IFlowEvent, + IFlowGroupedPolicies, + IFlowOrchestrator, + IFlowOrchestratorLeg, + IFlowOrchestratorRun, + IFlowPolicy, + 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', +}; + +const STRATEGY_VERB: Record = { + router: 'dispatched', + burnRouter: 'burnt', + claimer: 'claimed', + multiDispatch: 'dispatched', + multiRouter: 'dispatched', + multiClaimer: 'claimed', + uniswapRouter: 'swapped', + cowSwapRouter: 'swapped', +}; + +const EVENT_KIND: Record = { + INSTALLED: 'policyInstalled', + INITIALIZED: 'settingsUpdated', + UNINSTALLED: 'policyUninstalled', + SETTINGS_UPDATED: 'settingsUpdated', + FAILSAFE_UPDATED: 'settingsUpdated', + STRATEGY_FAILED: 'dispatchFailed', +}; + +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', +}; + +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). + */ +const isOutboundTransfer = (t: IEnvioExecutionTransfer): boolean => + t.decodedFrom !== 'unknown' && t.decodedFrom !== 'swapIn'; + +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; +}; + +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 }; +}; + +// --------------------------------------------------------------------------- +// 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); + + // 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); + 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, + }; + } + + 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, + }; +}; + +// --------------------------------------------------------------------------- +// 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 ( + restPolicy?.strategy.source?.type !== + PolicyStrategySourceType.STREAM_BALANCE + ) { + return null; + } + if (!policy.lastDispatchAt) { + 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, + }; +}; + +// --------------------------------------------------------------------------- +// 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; + + const policyToken = + 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)); + const policyTotalDistributed = + 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, + ), + ); + const events = envioPolicy.events.map((e) => mapEvent(e, proposalByTxHash)); + // 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: + events.length > 0 + ? events + : [ + { + id: `${envioPolicy.id}-installed`, + kind: 'policyInstalled', + at: isoFromSeconds(envioPolicy.installedAt), + title: 'Policy installed', + description: `Installed at block ${envioPolicy.installBlockNumber}.`, + txHash: installTxHash, + proposalId: installAttribution?.proposalId, + proposalSlug: installAttribution?.slug, + }, + ], + 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}`, + })), + }, + }; +}; + +// --------------------------------------------------------------------------- +// Orchestrator synthesis (Multi-dispatch) +// --------------------------------------------------------------------------- + +const buildOrchestrator = (params: { + envioPolicy: IEnvioPolicy; + restPolicy: IDaoPolicy | undefined; + policiesByAddress: Map; + proposalByTxHash: ProposalByTxHash | undefined; +}): IFlowOrchestrator => { + const { envioPolicy, restPolicy, policiesByAddress, proposalByTxHash } = + params; + + const subRouterAddresses = (restPolicy?.strategy.subRouters ?? []).map( + (a) => a.toLowerCase(), + ); + const chain: Array = subRouterAddresses.map( + (addr) => policiesByAddress.get(addr) ?? 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 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 = Array.from(runsByTxHash.values()).sort( + (a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(), + ); + + 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, + runs, + 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; + + // 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. + const orchestrators: IFlowOrchestrator[] = []; + 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, + }), + ); + } 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, + 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..4dc4442e3e --- /dev/null +++ b/src/modules/flow/utils/flowAddressBook.ts @@ -0,0 +1,176 @@ +/** + * 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', +}; + +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 addresses take the lowest precedence so a DAO that happens to use a + // burn address for some reason still wins. + for (const [addr, label] of Object.entries(BURN_ADDRESSES)) { + entries.set(normalizeAddress(addr), { + label, + role: 'burn', + }); + } + + // 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/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..b7f0041c65 --- /dev/null +++ b/src/shared/api/flowIndexer/flowIndexerTypes.ts @@ -0,0 +1,121 @@ +/** + * 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' + | 'unknown'; + +export interface IEnvioExecutionTransfer { + id: string; + amount: string; + to: string; + decodedFrom: EnvioTransferDecodedFrom; + actionIndex: number; + token: IEnvioToken; +} + +export type EnvioExecutionKind = 'DISPATCH' | 'CLAIM'; + +export interface IEnvioPolicyExecution { + id: string; + kind: EnvioExecutionKind; + blockNumber: string; + blockTimestamp: string; + txHash: string; + logIndex: number; + transferCount: number; + decodedTransferCount: number; + transfers: IEnvioExecutionTransfer[]; +} + +export type EnvioPolicyEventKind = + | 'INSTALLED' + | 'INITIALIZED' + | 'UNINSTALLED' + | 'SETTINGS_UPDATED' + | 'FAILSAFE_UPDATED' + | 'STRATEGY_FAILED'; + +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'; + +export interface IEnvioDao { + id: string; + address: string; + chainId: string; +} + +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[]; +} + +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; +} + +export interface IFlowDaoDataResponse { + Policy: IEnvioPolicy[]; + RecipientAggregate: IEnvioRecipientAggregate[]; +} diff --git a/src/shared/api/flowIndexer/index.ts b/src/shared/api/flowIndexer/index.ts new file mode 100644 index 0000000000..aabd2762f0 --- /dev/null +++ b/src/shared/api/flowIndexer/index.ts @@ -0,0 +1,23 @@ +export { + FlowIndexerError, + flowIndexerRequest, + getFlowIndexerEndpoint, + isFlowIndexerEnabled, +} from './flowIndexerClient'; +export { FlowIndexerKey, flowIndexerKeys } from './flowIndexerKeys'; +export type { + EnvioExecutionKind, + EnvioPolicyEventKind, + EnvioPolicyStatus, + EnvioStrategyType, + EnvioTransferDecodedFrom, + IEnvioDao, + IEnvioExecutionTransfer, + IEnvioPolicy, + IEnvioPolicyEvent, + IEnvioPolicyExecution, + IEnvioRecipientAggregate, + 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..25a8280354 --- /dev/null +++ b/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts @@ -0,0 +1,159 @@ +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`. + */ + +const FLOW_DAO_DATA_QUERY = /* GraphQL */ ` + 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 + } + lastExecution { + id + kind + blockNumber + blockTimestamp + txHash + logIndex + transferCount + decodedTransferCount + transfers(order_by: { actionIndex: asc }) { + id + amount + to + decodedFrom + actionIndex + token { + id + address + symbol + decimals + } + } + } + executions( + order_by: { blockTimestamp: desc } + limit: $executionLimit + ) { + id + kind + blockNumber + blockTimestamp + txHash + logIndex + transferCount + decodedTransferCount + transfers(order_by: { actionIndex: asc }) { + id + amount + to + decodedFrom + actionIndex + token { + id + address + symbol + decimals + } + } + } + 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 + } + } + } +`; + +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, + }); +}; From f4939d6291991511e433e2613de70bab37575e37 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 18 May 2026 16:25:12 +0200 Subject: [PATCH 02/13] feat: Enhance Flow module with new features and components - Added "Capital flow" link to navigation for improved access to flow functionalities. - Introduced Flow skeleton components for loading states across various Flow pages. - Updated Flow pages to handle loading and error states more gracefully. - Refactored FlowPoliciesSection to utilize new automation action hook for adding automations. - Renamed "Flow tree" to "Policy tree" for better clarity in the FlowPolicyTree component. --- src/assets/locales/en.json | 1 + .../navigationDao/navigationDaoUtils.ts | 14 + .../flow/components/flowLede/flowLede.tsx | 2 +- .../flowPoliciesSection.tsx | 26 +- .../flowPolicyStructure/flowPolicyTree.tsx | 2 +- .../flowSkeletons/flowSkeletons.tsx | 218 +++++ .../flow/components/flowSkeletons/index.ts | 7 + .../flow/components/flowTopbar/flowTopbar.tsx | 12 +- src/modules/flow/hooks/index.ts | 5 + .../flow/hooks/useAddAutomationAction.ts | 145 +++ src/modules/flow/hooks/useFlowData.ts | 21 +- src/modules/flow/mocks/mockFlowData.ts | 905 ------------------ .../flowActivityPageClient.tsx | 13 +- .../flowOverviewPageClient.tsx | 13 +- .../flowPolicyDetailPageClient.tsx | 27 +- .../flowRecipientsPageClient.tsx | 13 +- .../flow/providers/flowDataProvider.tsx | 111 ++- 17 files changed, 558 insertions(+), 977 deletions(-) create mode 100644 src/modules/flow/components/flowSkeletons/flowSkeletons.tsx create mode 100644 src/modules/flow/components/flowSkeletons/index.ts create mode 100644 src/modules/flow/hooks/useAddAutomationAction.ts delete mode 100644 src/modules/flow/mocks/mockFlowData.ts diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 6c0e839980..9070bac6bc 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", 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/flow/components/flowLede/flowLede.tsx b/src/modules/flow/components/flowLede/flowLede.tsx index eade13ccfb..8d229be0ea 100644 --- a/src/modules/flow/components/flowLede/flowLede.tsx +++ b/src/modules/flow/components/flowLede/flowLede.tsx @@ -83,7 +83,7 @@ export const FlowLede: React.FC = (props) => { return (

    - {dao.name} · Flow + {dao.name}

    {narrativeParts.join(' · ')}. diff --git a/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx b/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx index 32c9506a9f..aaccfb2750 100644 --- a/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx +++ b/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx @@ -1,8 +1,9 @@ 'use client'; import classNames from 'classnames'; -import Link from 'next/link'; import { useEffect, 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'; @@ -72,7 +73,14 @@ export const FlowPoliciesSection: React.FC = ( }, [pills, selected]); const active = pills.find((p) => p.id === selected) ?? pills[0]; - const addAutomationHref = `/dao/${network}/${addressOrEns}/settings/automations`; + + // 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 (

    @@ -111,14 +119,14 @@ export const FlowPoliciesSection: React.FC = (
    )}
    - - + Add automation ↗ - + + Add automation +
    {active && active.policies.length > 0 ? ( diff --git a/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx b/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx index 49fdeadc89..4b4e2bbc90 100644 --- a/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx +++ b/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx @@ -192,7 +192,7 @@ export const FlowPolicyTree: React.FC = (props) => { >

    - Flow tree + Policy tree

    Source → routers → recipients. Hover a node to trace its 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/flowTopbar/flowTopbar.tsx b/src/modules/flow/components/flowTopbar/flowTopbar.tsx index befd50d2b5..2fee0642e4 100644 --- a/src/modules/flow/components/flowTopbar/flowTopbar.tsx +++ b/src/modules/flow/components/flowTopbar/flowTopbar.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; 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'; @@ -48,13 +49,16 @@ export const FlowTopbar: React.FC = (props) => { >
    + {/* "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. */} - - F - + Flow diff --git a/src/modules/flow/hooks/index.ts b/src/modules/flow/hooks/index.ts index 955e2f6fa8..46cf77100d 100644 --- a/src/modules/flow/hooks/index.ts +++ b/src/modules/flow/hooks/index.ts @@ -1 +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 index 0fa0cdec9d..b2aa8ace86 100644 --- a/src/modules/flow/hooks/useFlowData.ts +++ b/src/modules/flow/hooks/useFlowData.ts @@ -11,10 +11,27 @@ export interface IUseFlowDataParams { 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): IFlowDaoData => - useFlowDataContext().data; +export const useFlowData = ( + _params: IUseFlowDataParams, +): IUseFlowDataResult => { + const { data, isLoading, isError } = useFlowDataContext(); + return { data, isLoading, isError }; +}; diff --git a/src/modules/flow/mocks/mockFlowData.ts b/src/modules/flow/mocks/mockFlowData.ts deleted file mode 100644 index e5e9e26108..0000000000 --- a/src/modules/flow/mocks/mockFlowData.ts +++ /dev/null @@ -1,905 +0,0 @@ -import type { - FlowTokenSymbol, - IFlowDao, - IFlowDaoData, - IFlowDispatch, - IFlowEvent, - IFlowPolicy, - IFlowPolicySubRouter, - IFlowRecipient, - IFlowRecipientAggregate, -} from '../types'; -import { groupPolicies } from '../utils/envioFlowMapper'; - -const DAY = 24 * 60 * 60 * 1000; - -const now = Date.UTC(2026, 3, 22, 15, 0, 0); - -const daysAgo = (d: number): string => new Date(now - d * DAY).toISOString(); - -const hoursAgo = (h: number): string => - new Date(now - h * 60 * 60 * 1000).toISOString(); - -const daysFromNow = (d: number): string => - new Date(now + d * DAY).toISOString(); - -const makeSeededRng = (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 makeTxHash = (rand: () => number): string => - `0x${Math.floor(rand() * 1e16) - .toString(16) - .padStart(12, '0') - .slice(0, 12)}…`; - -const generateDispatches = (params: { - policyId: string; - count: number; - token: FlowTokenSymbol; - amountRange: [number, number]; - topRecipients: IFlowRecipient[]; - spanDays: number; - startDaysAgo: number; -}): IFlowDispatch[] => { - const { - policyId, - count, - token, - amountRange, - topRecipients, - spanDays, - startDaysAgo, - } = params; - const rand = makeSeededRng(`dispatch:${policyId}`); - const items: IFlowDispatch[] = []; - for (let i = 0; i < count; i += 1) { - const progress = (i + 0.5) / count; - const daysBack = startDaysAgo - progress * spanDays; - const amount = - amountRange[0] + rand() * (amountRange[1] - amountRange[0]); - items.push({ - id: `${policyId}-d-${i}`, - at: daysAgo(daysBack), - amount, - token, - recipientsCount: - 1 + Math.floor(rand() * Math.max(topRecipients.length, 5)), - topRecipients, - txHash: makeTxHash(rand), - }); - } - return items; -}; - -const payrollRecipients: IFlowRecipient[] = [ - { name: 'alice.eth', pct: 14, address: '0xa11ce…b0b' }, - { - name: 'core-team.money-machine.eth', - pct: 12, - address: '0xc01e…team', - }, - { name: 'mallory.eth', pct: 10, address: '0xma11…0ry' }, -]; - -const payrollFullRecipients: IFlowRecipient[] = [ - { ratio: '14%', name: 'alice.eth', address: '0xa11ce…b0b' }, - { - ratio: '12%', - name: 'core-team.money-machine.eth', - address: '0xc01e…team', - }, - { ratio: '10%', name: 'mallory.eth', address: '0xma11…0ry' }, - { ratio: '9%', name: 'quinn.eth', address: '0xqu11n…7e2d' }, - { ratio: '9%', name: 'nina.eth', address: '0xn1na…a4c0' }, - { ratio: '8%', name: 'yoko.eth', address: '0xyok0…ff11' }, - { ratio: '8%', name: 'levi.eth', address: '0x1ev1…2b98' }, - { ratio: '7%', name: 'uma.eth', address: '0xuma1…19aa' }, - { ratio: '7%', name: 'penn.eth', address: '0xpenn…dd33' }, - { ratio: '6%', name: '0xf3…a12c', address: '0xf3b2…a12c' }, - { ratio: '5%', name: '0x2a…9901', address: '0x2a1d…9901' }, - { ratio: '5%', name: '0x41…c0de', address: '0x41ef…c0de' }, -]; - -const lpRecipients: IFlowRecipient[] = [ - { name: 'MERC/USDC · Uni v3', pct: 38, address: '0xpool…u3-1' }, - { name: 'MERC/WETH · Uni v3', pct: 29, address: '0xpool…u3-2' }, - { name: 'MERC/DAI · Curve', pct: 18, address: '0xpool…curv' }, -]; - -const lpFullRecipients: IFlowRecipient[] = [ - { ratio: '38%', name: 'MERC/USDC · Uni v3', address: '0xp001…u3-1' }, - { ratio: '29%', name: 'MERC/WETH · Uni v3', address: '0xp002…u3-2' }, - { ratio: '18%', name: 'MERC/DAI · Curve', address: '0xp003…curv' }, - { ratio: '10%', name: 'MERC/USDT · Bal', address: '0xp004…ba11' }, - { ratio: '5%', name: 'MERC/stETH · Curve', address: '0xp005…steh' }, -]; - -const burnRecipients: IFlowRecipient[] = [ - { name: 'Burn address', pct: 100, address: '0x0000…dead' }, -]; - -const sweepRecipients: IFlowRecipient[] = [ - { name: 'Treasury · USDC vault', pct: 100, address: '0xc0w5…wa9p' }, -]; - -const grantsRecipients: IFlowRecipient[] = [ - { name: 'optics-labs.eth', pct: 26, address: '0xop71…lab5' }, - { name: 'spellbook.eth', pct: 22, address: '0x5pe11…b00k' }, - { name: '0x9a…42be', pct: 18, address: '0x9a5c…42be' }, -]; - -const grantsFullRecipients: IFlowRecipient[] = [ - { ratio: '26%', name: 'optics-labs.eth', address: '0xop71…lab5' }, - { ratio: '22%', name: 'spellbook.eth', address: '0x5pe11…b00k' }, - { ratio: '18%', name: '0x9a…42be', address: '0x9a5c…42be' }, - { ratio: '12%', name: 'research-dao.eth', address: '0xr35e…a4c0' }, - { ratio: '10%', name: 'bluehat.eth', address: '0xb1ue…ha70' }, - { ratio: '8%', name: 'darknet.eth', address: '0xda6k…ne71' }, - { ratio: '4%', name: '0x17…beef', address: '0x1742…beef' }, -]; - -const grantsStreamRecipients: IFlowRecipient[] = [ - { name: 'research-dao.eth', pct: 40, address: '0xr35e…a4c0' }, - { name: 'bluehat.eth', pct: 35, address: '0xb1ue…ha70' }, - { name: 'optics-labs.eth', pct: 25, address: '0xop71…lab5' }, -]; - -const grantsStreamFullRecipients: IFlowRecipient[] = [ - { ratio: '40%', name: 'research-dao.eth', address: '0xr35e…a4c0' }, - { ratio: '35%', name: 'bluehat.eth', address: '0xb1ue…ha70' }, - { ratio: '25%', name: 'optics-labs.eth', address: '0xop71…lab5' }, -]; - -const multiRecipients: IFlowRecipient[] = [ - { - name: 'Contributor payroll · sub', - pct: 60, - address: 'policy:payroll', - }, - { name: 'LP incentives · sub', pct: 40, address: 'policy:lp' }, -]; - -const multiSubRouters: IFlowPolicySubRouter[] = [ - { - id: 'multi-sub-a', - title: 'Buyback Engine', - subtitle: 'sub-policy A · 55%', - allowance: { - type: 'Swap allowance', - detail: '55% of inbound · Uniswap v4', - }, - model: { - type: 'Burn router', - detail: 'Swap USDC → MERC → burn', - }, - recipients: [ - { ratio: '100%', name: 'Burn MERC', address: '0x0000…dead' }, - ], - }, - { - id: 'multi-sub-b', - title: 'LP + Contributor', - subtitle: 'sub-policy B · 30%', - allowance: { - type: 'Swap allowance', - detail: '30% of inbound · CoW Swap', - }, - model: { - type: 'Ratio splitter', - detail: 'Swap USDC → WETH, split into 3 legs', - }, - recipients: [ - { ratio: '45%', name: 'LP 0.3% Pool', address: '0xp003…lp03' }, - { ratio: '40%', name: 'Contributor Safe', address: '0xc017…safe' }, - { ratio: '15%', name: 'Bug Bounty', address: '0xbu6b…0unt' }, - ], - subRouters: [ - { - id: 'multi-sub-b-nested', - title: 'Contributor Safe · stream', - subtitle: 'nested · 40%', - allowance: { - type: 'Stream', - detail: '40% of sub-policy B per epoch', - }, - model: { - type: 'Ratio splitter', - detail: '3 contributors, voter-weighted', - }, - recipients: [ - { ratio: '50%', name: 'alice.eth', address: '0xa11ce…b0b' }, - { - ratio: '30%', - name: 'core-team.money-machine.eth', - address: '0xc01e…team', - }, - { - ratio: '20%', - name: 'mallory.eth', - address: '0xma11…0ry', - }, - ], - }, - ], - }, - { - id: 'multi-sub-c', - title: 'Ops Multisig', - subtitle: 'sub-policy C · 15%', - allowance: { - type: 'Transfer', - detail: '15% of inbound · direct transfer', - }, - model: { - type: 'Solo recipient', - detail: '100% to ops multisig', - }, - recipients: [ - { ratio: '100%', name: 'Ops Multisig', address: '0x0p5a…c0de' }, - ], - }, -]; - -const buildPayrollPolicy = (): Omit => ({ - id: 'payroll', - name: 'Contributor payroll', - description: 'Streams weekly USDC to 12 contributors via a ratio splitter.', - strategy: 'Stream', - strategyLong: 'Stream · weekly epoch', - token: 'USDC', - status: 'live', - statusLabel: 'Streaming · next in 2d', - verb: 'streamed', - createdAt: daysAgo(120), - installedViaProposal: '#42', - installedViaProposalSlug: 'proposal-42', - installTxHash: '0xinst…0042', - totalDistributed: 48_900, - forecast30d: 12_800, - nextDispatchLabel: 'Streams weekly · next in 2d', - nextDispatchAt: daysFromNow(2), - pending: null, - cooldown: { readyAt: daysFromNow(2), totalMs: 7 * DAY }, - lastDispatch: { - amount: 3100, - token: 'USDC', - at: hoursAgo(2), - txHash: '0x9aef…22c1', - recipientsCount: 12, - }, - recipients: payrollRecipients, - recipientsMore: 9, - recipientGroup: 'Contributors (12)', - dispatches: generateDispatches({ - policyId: 'payroll', - count: 16, - token: 'USDC', - amountRange: [2800, 3200], - topRecipients: payrollRecipients, - spanDays: 112, - startDaysAgo: 112, - }), - events: [ - { - id: 'payroll-installed', - kind: 'policyInstalled', - at: daysAgo(120), - title: 'Policy installed', - description: 'Contributor payroll deployed via proposal #42.', - proposalId: '#42', - proposalSlug: 'proposal-42', - txHash: '0xinst…0042', - }, - { - id: 'payroll-recipients-updated', - kind: 'recipientsUpdated', - at: daysAgo(70), - title: 'Recipients updated', - description: '+2 contributors added (quinn.eth, nina.eth).', - proposalId: '#48', - proposalSlug: 'proposal-48', - }, - { - id: 'payroll-settings-updated', - kind: 'settingsUpdated', - at: daysAgo(28), - title: 'Settings updated', - description: 'Epoch length changed from 14d to 7d.', - proposalId: '#57', - proposalSlug: 'proposal-57', - }, - ], - schema: { - source: 'Treasury vault · 0x9fA7…2e01', - allowance: { - type: 'Stream', - detail: '3,100 USDC per epoch · every 7 days', - }, - model: { - type: 'Ratio splitter', - detail: '12 recipients with individual weights', - }, - recipients: payrollFullRecipients, - }, -}); - -const buildLpPolicy = (): Omit => ({ - id: 'lp', - name: 'LP incentives', - description: 'Gauge-weighted MERC incentives to 5 LP farms every 7 days.', - strategy: 'Epoch transfer', - strategyLong: 'Epoch transfer · gauge-weighted', - token: 'MERC', - status: 'ready', - statusLabel: 'Ready · 50,000 MERC queued', - verb: 'distributed', - createdAt: daysAgo(96), - installedViaProposal: '#47', - installedViaProposalSlug: 'proposal-47', - installTxHash: '0xinst…0047', - totalDistributed: 820_000, - forecast30d: 210_000, - nextDispatchLabel: 'Ready to dispatch', - nextDispatchAt: hoursAgo(4), - pending: { amount: 50_000, token: 'MERC' }, - cooldown: null, - lastDispatch: { - amount: 50_000, - token: 'MERC', - at: daysAgo(7), - txHash: '0xd4ad…0a11', - recipientsCount: 5, - }, - recipients: lpRecipients, - recipientsMore: 2, - recipientGroup: 'LP farms', - dispatches: generateDispatches({ - policyId: 'lp', - count: 13, - token: 'MERC', - amountRange: [45_000, 52_000], - topRecipients: lpRecipients, - spanDays: 90, - startDaysAgo: 97, - }), - events: [ - { - id: 'lp-installed', - kind: 'policyInstalled', - at: daysAgo(96), - title: 'Policy installed', - description: 'LP incentives deployed via proposal #47.', - proposalId: '#47', - proposalSlug: 'proposal-47', - }, - { - id: 'lp-proposal-applied', - kind: 'proposalApplied', - at: daysAgo(40), - title: 'Proposal applied', - description: 'Gauge weights updated by proposal #52.', - proposalId: '#52', - proposalSlug: 'proposal-52', - }, - ], - schema: { - source: 'Treasury vault · 0x9fA7…2e01', - allowance: { - type: 'Fixed per epoch', - detail: '50,000 MERC per epoch · every 7 days', - }, - model: { - type: 'Gauge-weighted', - detail: 'Weighted by MERC holder votes, settled at epoch close', - }, - recipients: lpFullRecipients, - }, -}); - -const buildBurnPolicy = (): Omit => ({ - id: 'burn', - name: 'Fee burn', - description: 'Burns 100% of collected protocol fees at every epoch.', - strategy: 'Burn', - strategyLong: 'Burn router · single-token', - token: 'MERC', - status: 'cooldown', - statusLabel: 'Cooldown · ready in 4d', - verb: 'burned', - createdAt: daysAgo(80), - installedViaProposal: '#51', - installedViaProposalSlug: 'proposal-51', - installTxHash: '0xinst…0051', - totalDistributed: 445_000, - forecast30d: 115_000, - nextDispatchLabel: 'Next burn in 4d', - nextDispatchAt: daysFromNow(4), - pending: { amount: 28_000, token: 'MERC' }, - cooldown: { readyAt: daysFromNow(4), totalMs: 7 * DAY }, - lastDispatch: { - amount: 28_000, - token: 'MERC', - at: daysAgo(3), - txHash: '0x0b77…dead', - recipientsCount: 1, - }, - recipients: burnRecipients, - recipientsMore: 0, - recipientGroup: 'Burn', - dispatches: generateDispatches({ - policyId: 'burn', - count: 11, - token: 'MERC', - amountRange: [24_000, 32_000], - topRecipients: burnRecipients, - spanDays: 77, - startDaysAgo: 77, - }), - events: [ - { - id: 'burn-installed', - kind: 'policyInstalled', - at: daysAgo(80), - title: 'Policy installed', - description: 'Fee burn deployed via proposal #51.', - proposalId: '#51', - proposalSlug: 'proposal-51', - }, - ], - schema: { - source: 'Fee collector · 0xFee5…b001', - allowance: { - type: 'Drain', - detail: 'Full balance transferred at each epoch', - }, - model: { - type: 'Solo recipient', - detail: '100% to a single address', - }, - recipients: [ - { ratio: '100%', name: 'Burn address', address: '0x0000…dead' }, - ], - }, -}); - -const buildSweepPolicy = (): Omit => ({ - id: 'sweep', - name: 'Stable sweep', - description: - 'Monthly CoW-Swap converts WETH fees to USDC for the treasury.', - strategy: 'CoW swap', - strategyLong: 'CoW swap · WETH → USDC · monthly', - token: 'WETH', - status: 'cooldown', - statusLabel: 'Cooldown · window opens in 9d', - verb: 'swapped', - createdAt: daysAgo(65), - installedViaProposal: '#55', - installedViaProposalSlug: 'proposal-55', - installTxHash: '0xinst…0055', - totalDistributed: 94.1, - forecast30d: 14.0, - nextDispatchLabel: 'Claim window opens in 9d', - nextDispatchAt: daysFromNow(9), - pending: { amount: 12.4, token: 'WETH' }, - cooldown: { readyAt: daysFromNow(9), totalMs: 30 * DAY }, - lastDispatch: { - amount: 12.4, - token: 'WETH', - at: daysAgo(11), - txHash: '0xcafe…b4be', - recipientsCount: 1, - }, - recipients: sweepRecipients, - recipientsMore: 0, - recipientGroup: 'DAO vaults', - dispatches: generateDispatches({ - policyId: 'sweep', - count: 4, - token: 'WETH', - amountRange: [10, 18], - topRecipients: sweepRecipients, - spanDays: 60, - startDaysAgo: 62, - }), - events: [ - { - id: 'sweep-installed', - kind: 'policyInstalled', - at: daysAgo(65), - title: 'Policy installed', - description: 'Stable sweep deployed via proposal #55.', - proposalId: '#55', - proposalSlug: 'proposal-55', - }, - ], - schema: { - source: 'Fee collector · 0xFee5…b001', - allowance: { - type: 'Drain', - detail: 'Full WETH balance at each epoch', - }, - model: { - type: 'Solo recipient', - detail: 'Proceeds routed to treasury USDC vault', - }, - recipients: [ - { - ratio: '100%', - name: 'Treasury · USDC vault', - address: '0xc0w5…wa9p', - }, - ], - }, -}); - -const buildGrantsPolicy = (): Omit => ({ - id: 'grants', - name: 'Grants program', - description: - 'Claim-window distribution of approved grants up to 25k USDC each.', - strategy: 'Claimer', - strategyLong: 'Claimer policy · approved claims', - token: 'USDC', - status: 'live', - statusLabel: 'Claim window open · 4,180 USDC claimable', - verb: 'claimed', - createdAt: daysAgo(50), - installedViaProposal: '#61', - installedViaProposalSlug: 'proposal-61', - installTxHash: '0xinst…0061', - totalDistributed: 128_500, - forecast30d: 30_000, - nextDispatchLabel: 'Claim-driven · no scheduled dispatch', - nextDispatchAt: hoursAgo(-20), - pending: { amount: 4180, token: 'USDC' }, - cooldown: null, - failedLastDispatch: { - at: daysAgo(18), - reason: 'Slippage tolerance exceeded (0.8% vs 0.5% max).', - txHash: '0xfa11…ed18', - }, - lastDispatch: { - amount: 7300, - token: 'USDC', - at: hoursAgo(4), - txHash: '0xa1b2…14de', - recipientsCount: 1, - }, - recipients: grantsRecipients, - recipientsMore: 4, - recipientGroup: 'External EOAs', - dispatches: [ - ...generateDispatches({ - policyId: 'grants', - count: 9, - token: 'USDC', - amountRange: [3000, 9000], - topRecipients: grantsRecipients, - spanDays: 46, - startDaysAgo: 46, - }), - { - id: 'grants-d-failed-1', - at: daysAgo(18), - amount: 4800, - token: 'USDC', - recipientsCount: 1, - topRecipients: grantsRecipients, - txHash: '0xfa11…ed18', - status: 'failed', - failureReason: 'Slippage tolerance exceeded', - }, - ], - events: [ - { - id: 'grants-installed', - kind: 'policyInstalled', - at: daysAgo(50), - title: 'Policy installed', - description: 'Grants program deployed via proposal #61.', - proposalId: '#61', - proposalSlug: 'proposal-61', - }, - { - id: 'grants-dispatch-failed', - kind: 'dispatchFailed', - at: daysAgo(18), - title: 'Dispatch failed', - description: - 'Swap reverted — slippage tolerance exceeded (0.8% vs 0.5% max).', - txHash: '0xfa11…ed18', - }, - { - id: 'grants-settings-updated', - kind: 'settingsUpdated', - at: daysAgo(12), - title: 'Settings updated', - description: - 'Per-claim cap raised from 10k to 25k USDC; slippage tolerance relaxed.', - proposalId: '#67', - proposalSlug: 'proposal-67', - }, - ], - schema: { - source: 'Grants vault · 0x6ra7…c0de', - allowance: { - type: 'Pull', - detail: 'Claimants pull approved amounts in the window', - }, - model: { - type: 'Tiered', - detail: 'Per-claim cap: up to 25,000 USDC', - }, - recipients: grantsFullRecipients, - }, -}); - -const buildGrantsStreamPolicy = (): Omit => ({ - id: 'grants-stream', - name: 'Research grants stream', - description: 'Quarterly grant stream to 3 research collectives.', - strategy: 'Stream', - strategyLong: 'Stream · quarterly epoch', - token: 'USDC', - status: 'awaiting', - statusLabel: 'Awaiting proposal #71', - verb: 'streamed', - createdAt: daysAgo(12), - installedViaProposal: '#69', - installedViaProposalSlug: 'proposal-69', - installTxHash: '0xinst…0069', - totalDistributed: 0, - forecast30d: 0, - nextDispatchLabel: 'Awaiting proposal #71', - nextDispatchAt: undefined, - pending: null, - cooldown: null, - lastDispatch: undefined, - recipients: grantsStreamRecipients, - recipientsMore: 0, - recipientGroup: 'Research DAOs', - dispatches: [], - events: [ - { - id: 'grants-stream-installed', - kind: 'policyInstalled', - at: daysAgo(12), - title: 'Policy installed', - description: - 'Research grants stream scaffolded via proposal #69 — awaiting activation.', - proposalId: '#69', - proposalSlug: 'proposal-69', - }, - { - id: 'grants-stream-settings-updated', - kind: 'settingsUpdated', - at: daysAgo(4), - title: 'Settings updated', - description: - 'Quarterly cadence confirmed, awaiting activation vote in proposal #71.', - proposalId: '#71', - proposalSlug: 'proposal-71', - }, - ], - schema: { - source: 'Treasury vault · 0x9fA7…2e01', - allowance: { - type: 'Stream', - detail: '12,500 USDC per epoch · every 90 days', - }, - model: { - type: 'Ratio splitter', - detail: '3 recipients · voter-weighted', - }, - recipients: grantsStreamFullRecipients, - }, -}); - -const buildMultiPolicy = (): Omit => ({ - id: 'multi', - name: 'Cross-program distribution', - description: - 'Routes fee sweeps across buyback, LP, and contributor subpolicies.', - strategy: 'Multi-dispatch', - strategyLong: 'Multi-dispatch · chained routers', - token: 'MERC', - status: 'paused', - statusLabel: 'Paused · review pending', - verb: 'distributed', - createdAt: daysAgo(45), - installedViaProposal: '#63', - installedViaProposalSlug: 'proposal-63', - installTxHash: '0xinst…0063', - totalDistributed: 85_000, - forecast30d: 0, - nextDispatchLabel: 'Paused · review pending', - pending: null, - cooldown: null, - lastDispatch: { - amount: 42_000, - token: 'MERC', - at: daysAgo(22), - txHash: '0xmu17…21c3', - recipientsCount: 2, - }, - recipients: multiRecipients, - recipientsMore: 0, - recipientGroup: 'Sub-routers', - dispatches: generateDispatches({ - policyId: 'multi', - count: 2, - token: 'MERC', - amountRange: [40_000, 45_000], - topRecipients: multiRecipients, - spanDays: 18, - startDaysAgo: 40, - }), - events: [ - { - id: 'multi-installed', - kind: 'policyInstalled', - at: daysAgo(45), - title: 'Policy installed', - description: - 'Cross-program distribution deployed via proposal #63.', - proposalId: '#63', - proposalSlug: 'proposal-63', - }, - { - id: 'multi-paused', - kind: 'paused', - at: daysAgo(10), - title: 'Policy paused', - description: 'Paused for post-incident review.', - proposalId: '#68', - proposalSlug: 'proposal-68', - }, - ], - schema: { - source: 'Treasury vault · 0x9fA7…2e01', - allowance: { - type: 'Fixed per epoch', - detail: '100,000 MERC per epoch · every 14 days', - }, - model: { - type: 'Ratio splitter', - detail: 'Routes to nested policies', - }, - recipients: [ - { - ratio: '60%', - name: 'Contributor payroll (sub-router)', - address: 'policy:payroll', - }, - { - ratio: '40%', - name: 'LP incentives (sub-router)', - address: 'policy:lp', - }, - ], - subRouters: multiSubRouters, - }, -}); - -const buildPolicies = (): Omit[] => [ - buildPayrollPolicy(), - buildLpPolicy(), - buildBurnPolicy(), - buildSweepPolicy(), - buildGrantsPolicy(), - buildGrantsStreamPolicy(), - buildMultiPolicy(), -]; - -const buildRecipientsAggregate = ( - policies: IFlowPolicy[], -): IFlowRecipientAggregate[] => { - const map = new Map(); - for (const policy of policies) { - for (const dispatch of policy.dispatches) { - if (dispatch.status === 'failed') { - continue; - } - const top = dispatch.topRecipients[0]; - if (top == null) { - continue; - } - const key = top.address; - const existing = map.get(key); - const share = typeof top.pct === 'number' ? top.pct / 100 : 1; - const contribution = dispatch.amount * share; - if (existing) { - existing.amountsByToken[dispatch.token] = - (existing.amountsByToken[dispatch.token] ?? 0) + - contribution; - existing.dispatchCount += 1; - if ( - new Date(dispatch.at).getTime() > - new Date(existing.lastReceivedAt).getTime() - ) { - existing.lastReceivedAt = dispatch.at; - } - if (!existing.fromPolicyIds.includes(policy.id)) { - existing.fromPolicyIds.push(policy.id); - } - } else { - map.set(key, { - address: top.address, - name: top.name, - group: policy.recipientGroup, - fromPolicyIds: [policy.id], - amountsByToken: { - [dispatch.token]: contribution, - } as Partial>, - dispatchCount: 1, - lastReceivedAt: dispatch.at, - }); - } - } - } - return Array.from(map.values()).sort( - (a, b) => - new Date(b.lastReceivedAt).getTime() - - new Date(a.lastReceivedAt).getTime(), - ); -}; - -const buildDao = (network: string, addressOrEns: string): IFlowDao => ({ - network, - addressOrEns, - name: 'Money Machine DAO', - avatarColor: '#003bf5', -}); - -export const getMockFlowData = ( - network: string, - addressOrEns: string, -): IFlowDaoData => { - const rawPolicies = buildPolicies(); - // Inject synthetic `address` values so the mock satisfies `IFlowPolicy.address` (used - // by the pills grouping + external-edit links). Real Envio data carries the actual - // plugin address. - const policies = rawPolicies.map((p) => ({ - ...p, - address: p.id.startsWith('0x') - ? p.id - : (`0xmock${p.id.padEnd(36, '0')}`.slice(0, 42) as string), - })); - const recipients = buildRecipientsAggregate(policies); - return { - dao: buildDao(network, addressOrEns), - policies, - groupedPolicies: groupPolicies(policies), - orchestrators: [], - recipients, - }; -}; - -export { buildRecipientsAggregate }; - -export const getAllDispatches = (data: IFlowDaoData): IFlowDispatch[] => - data.policies - .flatMap((policy) => - policy.dispatches.map((dispatch) => ({ - ...dispatch, - policyId: policy.id, - })), - ) - .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); - -export const getAllEvents = (data: IFlowDaoData): IFlowEvent[] => - data.policies - .flatMap((policy) => policy.events) - .sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); diff --git a/src/modules/flow/pages/flowActivityPage/flowActivityPageClient.tsx b/src/modules/flow/pages/flowActivityPage/flowActivityPageClient.tsx index 5b8f68fb58..306161bc44 100644 --- a/src/modules/flow/pages/flowActivityPage/flowActivityPageClient.tsx +++ b/src/modules/flow/pages/flowActivityPage/flowActivityPageClient.tsx @@ -1,6 +1,10 @@ 'use client'; import { FlowActivityFeed } from '../../components/flowActivityFeed/flowActivityFeed'; +import { + FlowActivityPageSkeleton, + FlowLoadError, +} from '../../components/flowSkeletons'; import { useFlowData } from '../../hooks'; export interface IFlowActivityPageClientProps { @@ -12,7 +16,14 @@ export const FlowActivityPageClient: React.FC = ( props, ) => { const { network, addressOrEns } = props; - const data = useFlowData({ network, addressOrEns }); + const { data, isError } = useFlowData({ network, addressOrEns }); + + if (data == null) { + if (isError) { + return ; + } + return ; + } return (
    diff --git a/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx b/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx index 424cdedfb1..461fdbd745 100644 --- a/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx +++ b/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx @@ -5,6 +5,10 @@ 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 { useFlowData } from '../../hooks'; export interface IFlowOverviewPageClientProps { @@ -16,7 +20,14 @@ export const FlowOverviewPageClient: React.FC = ( props, ) => { const { network, addressOrEns } = props; - const data = useFlowData({ network, addressOrEns }); + const { data, isError } = useFlowData({ network, addressOrEns }); + + if (data == null) { + if (isError) { + return ; + } + return ; + } return (
    diff --git a/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx index b5d8e92e3a..61b0a49ca5 100644 --- a/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx +++ b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx @@ -14,6 +14,10 @@ import { FlowWaitingForIndexerBadge, } from '../../components/flowPrimitives'; import { FlowRecipientsTable } from '../../components/flowRecipientsTable/flowRecipientsTable'; +import { + FlowLoadError, + FlowPolicyDetailPageSkeleton, +} from '../../components/flowSkeletons'; import { FlowDialogId } from '../../constants/flowDialogId'; import type { IConfirmDispatchDialogParams } from '../../dialogs/confirmDispatchDialog'; import { useFlowDataContext } from '../../providers/flowDataProvider'; @@ -35,25 +39,24 @@ export const FlowPolicyDetailPageClient: React.FC< IFlowPolicyDetailPageClientProps > = (props) => { const { network, addressOrEns, policyId } = props; - const { data, dispatchPolicy, getPendingDispatch, isEnvioLoading } = + const { data, dispatchPolicy, getPendingDispatch, isError } = useFlowDataContext(); const { open } = useDialogContext(); const decodedPolicyId = decodeURIComponent(policyId); + const base = `/dao/${network}/${addressOrEns}/flow`; + + if (data == null) { + if (isError) { + return ; + } + return ; + } + const policy = data.policies.find( (p) => p.id === decodedPolicyId || p.id === policyId, ); - const base = `/dao/${network}/${addressOrEns}/flow`; if (policy == null) { - if (isEnvioLoading) { - return ( -
    -
    -
    -
    -
    - ); - } return (

    @@ -67,7 +70,7 @@ export const FlowPolicyDetailPageClient: React.FC< className="font-normal text-primary-400 text-sm leading-tight hover:text-primary-600" href={base} > - ← Back to Flow overview + ← Back to overview

    ); diff --git a/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPageClient.tsx b/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPageClient.tsx index f8ad22b756..d2e7d92955 100644 --- a/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPageClient.tsx +++ b/src/modules/flow/pages/flowRecipientsPage/flowRecipientsPageClient.tsx @@ -1,6 +1,10 @@ 'use client'; import { FlowRecipientsTable } from '../../components/flowRecipientsTable/flowRecipientsTable'; +import { + FlowLoadError, + FlowRecipientsPageSkeleton, +} from '../../components/flowSkeletons'; import { useFlowData } from '../../hooks'; export interface IFlowRecipientsPageClientProps { @@ -12,7 +16,14 @@ export const FlowRecipientsPageClient: React.FC< IFlowRecipientsPageClientProps > = (props) => { const { network, addressOrEns } = props; - const data = useFlowData({ network, addressOrEns }); + const { data, isError } = useFlowData({ network, addressOrEns }); + + if (data == null) { + if (isError) { + return ; + } + return ; + } return (
    diff --git a/src/modules/flow/providers/flowDataProvider.tsx b/src/modules/flow/providers/flowDataProvider.tsx index 9aa7500d6c..4b29c067fb 100644 --- a/src/modules/flow/providers/flowDataProvider.tsx +++ b/src/modules/flow/providers/flowDataProvider.tsx @@ -20,7 +20,6 @@ import type { import type { Network } from '@/shared/api/daoService'; import { useDialogContext } from '@/shared/components/dialogProvider'; import { networkDefinitions } from '@/shared/constants/networkDefinitions'; -import { getMockFlowData } from '../mocks/mockFlowData'; import type { IFlowDaoData } from '../types'; import { useEnvioFlowData } from './useEnvioFlowData'; @@ -53,16 +52,28 @@ export interface IFlowPendingDispatch { } export interface IFlowDataContext { - data: IFlowDaoData; /** - * `true` while the Envio-backed snapshot is still being resolved. Consumers - * that render data-coupled UI (policy detail page, recipient detail page) - * should show a skeleton instead of "not found" until this flips to false. - * Always `false` when the Envio feature flag is off (mock-only mode). + * 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. */ - isEnvioLoading: boolean; - /** `true` once we've swapped the mock snapshot for the live Envio one. */ - hasEnvioData: boolean; + 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 @@ -94,10 +105,10 @@ export interface IFlowDataProviderProps { network: string; addressOrEns: string; /** - * DAO identifier (e.g. `ethereum-sepolia-0xabc…`) — required when the - * `NEXT_PUBLIC_FLOW_USE_ENVIO` flag is on so the provider can resolve DAO - * metadata + linked accounts via the REST API. Falls back to mock data - * when the flag is off or the indexer query fails. + * 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; @@ -117,10 +128,11 @@ export const FlowDataProvider: React.FC = (props) => { isUrgent: pendingDispatches.length > 0, }); - const [data, setData] = useState(() => - getMockFlowData(network, addressOrEns), - ); - const [hasEnvioData, setHasEnvioData] = useState(false); + // 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(); @@ -128,32 +140,30 @@ export const FlowDataProvider: React.FC = (props) => { const { check: checkWalletConnected } = useConnectedWalletGuard(); useEffect(() => { - if (envioResult.enabled) { - return; - } - setData(getMockFlowData(network, addressOrEns)); - setHasEnvioData(false); - }, [envioResult.enabled, network, addressOrEns]); - - useEffect(() => { - if (!envioResult.enabled) { - return; - } if (envioResult.data) { setData(envioResult.data); - setHasEnvioData(true); } - }, [envioResult.enabled, 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.enabled && envioResult.isError) { + if (envioResult.isError) { // biome-ignore lint/suspicious/noConsole: surface Envio query failures to aid debugging console.warn( - '[FlowDataProvider] Envio query failed, keeping previous snapshot.', + '[FlowDataProvider] Envio query failed.', envioResult.error, ); } - }, [envioResult.enabled, envioResult.isError, envioResult.error]); + }, [envioResult.isError, envioResult.error]); const pushToast = useCallback((toast: Omit) => { const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; @@ -220,6 +230,15 @@ export const FlowDataProvider: React.FC = (props) => { 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, @@ -311,6 +330,9 @@ export const FlowDataProvider: React.FC = (props) => { if (pendingDispatches.length === 0) { return; } + if (data == null) { + return; + } const indexedHashes = new Set(); for (const policy of data.policies) { for (const dispatch of policy.dispatches) { @@ -342,7 +364,7 @@ export const FlowDataProvider: React.FC = (props) => { description: `${pending.policyName} is now reflected in the feed.`, }); } - }, [data.policies, pendingDispatches, pushToast]); + }, [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 @@ -383,14 +405,22 @@ export const FlowDataProvider: React.FC = (props) => { [pendingDispatches], ); - const isEnvioLoading = - envioResult.enabled && !hasEnvioData && !envioResult.isError; + // 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 ?? ''; const value = useMemo( () => ({ + daoId: resolvedDaoId, data, - isEnvioLoading, - hasEnvioData, + isLoading, + isError, dispatchPolicy, pendingDispatches, getPendingDispatch, @@ -398,9 +428,10 @@ export const FlowDataProvider: React.FC = (props) => { dismissToast, }), [ + resolvedDaoId, data, - isEnvioLoading, - hasEnvioData, + isLoading, + isError, dispatchPolicy, pendingDispatches, getPendingDispatch, From cae370cdc56ba8d715f9c4fd552c4b3ee9b7ed09 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 25 May 2026 20:30:14 +0200 Subject: [PATCH 03/13] wip: lmm demo local working copy Safety-net snapshot of the Lido Money Machine demo files before running the smoke flow. Includes: - infra/lmm-demo/ runbook + VM stack - src/modules/flow/demo/ (override layer + safety) - src/modules/flow/components/lidoMoneyMachine/ (vendored UI) - src/shared/lidoPreview/ (vendored library) - capitalFlow dispatch dialog routing - docs/lido-mmd-*.md --- biome.json | 60 + config/.env.local | 16 + docs/lido-mmd-production-readiness.md | 95 ++ docs/lido-mmd-status.md | 61 + infra/lmm-demo/README.md | 219 +++ infra/lmm-demo/vercel.env.example | 23 + infra/lmm-demo/vm/.env.vm.example | 26 + infra/lmm-demo/vm/Caddyfile | 71 + infra/lmm-demo/vm/docker-compose.yml | 130 ++ infra/lmm-demo/vm/init-demo.sh | 102 ++ infra/lmm-demo/vm/vm-README.md | 168 ++ package.json | 2 + pnpm-lock.yaml | 153 ++ .../dispatchTransactionDialog.tsx | 27 + .../dispatchDialog/lmmDemoDispatchDialog.tsx | 255 +++ .../flowMultiDispatchCard.tsx | 141 +- .../flowPolicyChart/flowEventStyles.ts | 21 +- .../flowPolicyChart/flowPolicyChart.tsx | 92 +- .../flowPolicyStructure/flowPolicyTree.tsx | 47 + .../lidoMoneyMachine/ActionsMenu.tsx | 118 ++ .../lidoMoneyMachine/DispatchDialog.tsx | 117 ++ .../lidoMoneyMachine/LmmCheatsMenu.tsx | 174 ++ .../lidoMoneyMachine/LmmPolicyTopology.tsx | 113 ++ .../lidoMoneyMachine/NodeDetails.tsx | 484 ++++++ .../lidoMoneyMachine/StatusPanel.tsx | 77 + .../lidoMoneyMachine/StatusView.tsx | 381 +++++ .../components/lidoMoneyMachine/StepsView.tsx | 241 +++ .../lidoMoneyMachine/TopologyView.tsx | 421 +++++ .../components/lidoMoneyMachine/actions.ts | 356 ++++ .../components/lidoMoneyMachine/format.ts | 71 + .../components/lidoMoneyMachine/icons.tsx | 71 + .../components/lidoMoneyMachine/layout.ts | 47 + .../components/lidoMoneyMachine/styles.css | 1472 +++++++++++++++++ .../components/lidoMoneyMachine/useStatus.ts | 507 ++++++ src/modules/flow/demo/LmmDemoBanner.tsx | 48 + src/modules/flow/demo/lmmDaoOverride.ts | 323 ++++ src/modules/flow/demo/lmmDemoConfig.ts | 226 +++ src/modules/flow/demo/safety.test.ts | 64 + src/modules/flow/demo/safety.ts | 69 + .../flowOverviewPageClient.tsx | 2 + .../flowPolicyDetailPageClient.tsx | 8 + src/modules/flow/types.ts | 68 +- src/modules/flow/utils/envioFlowMapper.ts | 247 ++- .../api/daoService/queries/useDao/useDao.ts | 13 +- .../queries/useDaoByEns/useDaoByEns.ts | 13 +- .../useDaoPermissions/useDaoPermissions.ts | 12 +- .../queries/useDaoPolicies/useDaoPolicies.ts | 12 +- .../api/flowIndexer/flowIndexerTypes.ts | 117 +- src/shared/api/flowIndexer/index.ts | 9 + .../queries/useFlowIndexerDaoData.ts | 136 +- .../abi/generated/BurnDispatchStrategy.ts | 241 +++ .../abi/generated/DispatcherPlugin.ts | 607 +++++++ .../abi/generated/EpochProvider.ts | 144 ++ .../EpochTransferDispatchStrategy.ts | 336 ++++ .../abi/generated/EqualSplitter.ts | 199 +++ .../lidoPreview/abi/generated/FullBudget.ts | 121 ++ .../generated/GatedCowSwapDispatchStrategy.ts | 681 ++++++++ .../abi/generated/IDispatchBudget.ts | 48 + .../abi/generated/IDispatchSplitter.ts | 65 + .../abi/generated/IDispatchStrategy.ts | 91 + .../abi/generated/IDispatcherPlugin.ts | 173 ++ .../abi/generated/IEpochProvider.ts | 22 + .../abi/generated/MockCowSwapSettlement.ts | 112 ++ .../abi/generated/MockPriceOracle.ts | 121 ++ .../abi/generated/PriceFloorGate.ts | 300 ++++ .../abi/generated/RatioSplitter.ts | 239 +++ .../abi/generated/RequiredBudget.ts | 150 ++ .../lidoPreview/abi/generated/SoloSplitter.ts | 162 ++ .../abi/generated/StreamUntilBudget.ts | 296 ++++ .../abi/generated/TransferDispatchStrategy.ts | 300 ++++ .../UniV2LiquidityDispatchStrategy.ts | 410 +++++ .../abi/generated/WrapDispatchStrategy.ts | 270 +++ src/shared/lidoPreview/abi/generated/index.ts | 28 + .../components/budgets/fullBudget.ts | 42 + .../components/budgets/requiredBudget.ts | 48 + .../components/budgets/streamUntilBudget.ts | 76 + src/shared/lidoPreview/components/index.ts | 33 + .../components/plugins/dispatcherPlugin.ts | 64 + .../components/splitters/equalSplitter.ts | 33 + .../components/splitters/ratioSplitter.ts | 49 + .../components/splitters/soloSplitter.ts | 30 + .../components/strategies/_priceFloorGate.ts | 64 + .../components/strategies/burnDispatch.ts | 42 + .../strategies/epochTransferDispatch.ts | 85 + .../components/strategies/lidoGatedCowSwap.ts | 131 ++ .../strategies/lidoUniV2Liquidity.ts | 121 ++ .../components/strategies/lidoWrap.ts | 63 + .../components/strategies/transferDispatch.ts | 58 + src/shared/lidoPreview/index.ts | 27 + src/shared/lidoPreview/introspect/dispatch.ts | 170 ++ src/shared/lidoPreview/introspect/index.ts | 63 + src/shared/lidoPreview/introspect/token.ts | 51 + src/shared/lidoPreview/introspect/unknown.ts | 128 ++ src/shared/lidoPreview/registry/index.ts | 86 + src/shared/lidoPreview/registry/types.ts | 105 ++ src/shared/lidoPreview/render/reactFlow.ts | 394 +++++ .../lidoPreview/simulate/allocations.ts | 90 + src/shared/lidoPreview/simulate/budget.ts | 97 ++ src/shared/lidoPreview/simulate/chainState.ts | 230 +++ src/shared/lidoPreview/simulate/index.ts | 311 ++++ src/shared/lidoPreview/simulate/predictors.ts | 627 +++++++ src/shared/lidoPreview/types/flow.ts | 95 ++ src/shared/lidoPreview/types/index.ts | 8 + src/shared/lidoPreview/types/json.ts | 19 + src/shared/lidoPreview/types/primitives.ts | 41 + src/shared/lidoPreview/types/topology.ts | 240 +++ tsconfig.json | 2 +- 107 files changed, 16485 insertions(+), 80 deletions(-) create mode 100644 docs/lido-mmd-production-readiness.md create mode 100644 docs/lido-mmd-status.md create mode 100644 infra/lmm-demo/README.md create mode 100644 infra/lmm-demo/vercel.env.example create mode 100644 infra/lmm-demo/vm/.env.vm.example create mode 100644 infra/lmm-demo/vm/Caddyfile create mode 100644 infra/lmm-demo/vm/docker-compose.yml create mode 100755 infra/lmm-demo/vm/init-demo.sh create mode 100644 infra/lmm-demo/vm/vm-README.md create mode 100644 src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/ActionsMenu.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/DispatchDialog.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/NodeDetails.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/StatusPanel.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/StatusView.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/StepsView.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/actions.ts create mode 100644 src/modules/flow/components/lidoMoneyMachine/format.ts create mode 100644 src/modules/flow/components/lidoMoneyMachine/icons.tsx create mode 100644 src/modules/flow/components/lidoMoneyMachine/layout.ts create mode 100644 src/modules/flow/components/lidoMoneyMachine/styles.css create mode 100644 src/modules/flow/components/lidoMoneyMachine/useStatus.ts create mode 100644 src/modules/flow/demo/LmmDemoBanner.tsx create mode 100644 src/modules/flow/demo/lmmDaoOverride.ts create mode 100644 src/modules/flow/demo/lmmDemoConfig.ts create mode 100644 src/modules/flow/demo/safety.test.ts create mode 100644 src/modules/flow/demo/safety.ts create mode 100644 src/shared/lidoPreview/abi/generated/BurnDispatchStrategy.ts create mode 100644 src/shared/lidoPreview/abi/generated/DispatcherPlugin.ts create mode 100644 src/shared/lidoPreview/abi/generated/EpochProvider.ts create mode 100644 src/shared/lidoPreview/abi/generated/EpochTransferDispatchStrategy.ts create mode 100644 src/shared/lidoPreview/abi/generated/EqualSplitter.ts create mode 100644 src/shared/lidoPreview/abi/generated/FullBudget.ts create mode 100644 src/shared/lidoPreview/abi/generated/GatedCowSwapDispatchStrategy.ts create mode 100644 src/shared/lidoPreview/abi/generated/IDispatchBudget.ts create mode 100644 src/shared/lidoPreview/abi/generated/IDispatchSplitter.ts create mode 100644 src/shared/lidoPreview/abi/generated/IDispatchStrategy.ts create mode 100644 src/shared/lidoPreview/abi/generated/IDispatcherPlugin.ts create mode 100644 src/shared/lidoPreview/abi/generated/IEpochProvider.ts create mode 100644 src/shared/lidoPreview/abi/generated/MockCowSwapSettlement.ts create mode 100644 src/shared/lidoPreview/abi/generated/MockPriceOracle.ts create mode 100644 src/shared/lidoPreview/abi/generated/PriceFloorGate.ts create mode 100644 src/shared/lidoPreview/abi/generated/RatioSplitter.ts create mode 100644 src/shared/lidoPreview/abi/generated/RequiredBudget.ts create mode 100644 src/shared/lidoPreview/abi/generated/SoloSplitter.ts create mode 100644 src/shared/lidoPreview/abi/generated/StreamUntilBudget.ts create mode 100644 src/shared/lidoPreview/abi/generated/TransferDispatchStrategy.ts create mode 100644 src/shared/lidoPreview/abi/generated/UniV2LiquidityDispatchStrategy.ts create mode 100644 src/shared/lidoPreview/abi/generated/WrapDispatchStrategy.ts create mode 100644 src/shared/lidoPreview/abi/generated/index.ts create mode 100644 src/shared/lidoPreview/components/budgets/fullBudget.ts create mode 100644 src/shared/lidoPreview/components/budgets/requiredBudget.ts create mode 100644 src/shared/lidoPreview/components/budgets/streamUntilBudget.ts create mode 100644 src/shared/lidoPreview/components/index.ts create mode 100644 src/shared/lidoPreview/components/plugins/dispatcherPlugin.ts create mode 100644 src/shared/lidoPreview/components/splitters/equalSplitter.ts create mode 100644 src/shared/lidoPreview/components/splitters/ratioSplitter.ts create mode 100644 src/shared/lidoPreview/components/splitters/soloSplitter.ts create mode 100644 src/shared/lidoPreview/components/strategies/_priceFloorGate.ts create mode 100644 src/shared/lidoPreview/components/strategies/burnDispatch.ts create mode 100644 src/shared/lidoPreview/components/strategies/epochTransferDispatch.ts create mode 100644 src/shared/lidoPreview/components/strategies/lidoGatedCowSwap.ts create mode 100644 src/shared/lidoPreview/components/strategies/lidoUniV2Liquidity.ts create mode 100644 src/shared/lidoPreview/components/strategies/lidoWrap.ts create mode 100644 src/shared/lidoPreview/components/strategies/transferDispatch.ts create mode 100644 src/shared/lidoPreview/index.ts create mode 100644 src/shared/lidoPreview/introspect/dispatch.ts create mode 100644 src/shared/lidoPreview/introspect/index.ts create mode 100644 src/shared/lidoPreview/introspect/token.ts create mode 100644 src/shared/lidoPreview/introspect/unknown.ts create mode 100644 src/shared/lidoPreview/registry/index.ts create mode 100644 src/shared/lidoPreview/registry/types.ts create mode 100644 src/shared/lidoPreview/render/reactFlow.ts create mode 100644 src/shared/lidoPreview/simulate/allocations.ts create mode 100644 src/shared/lidoPreview/simulate/budget.ts create mode 100644 src/shared/lidoPreview/simulate/chainState.ts create mode 100644 src/shared/lidoPreview/simulate/index.ts create mode 100644 src/shared/lidoPreview/simulate/predictors.ts create mode 100644 src/shared/lidoPreview/types/flow.ts create mode 100644 src/shared/lidoPreview/types/index.ts create mode 100644 src/shared/lidoPreview/types/json.ts create mode 100644 src/shared/lidoPreview/types/primitives.ts create mode 100644 src/shared/lidoPreview/types/topology.ts 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 3514c05487..f15225ffa6 100644 --- a/config/.env.local +++ b/config/.env.local @@ -19,3 +19,19 @@ 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=0 +# 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/docs/lido-mmd-production-readiness.md b/docs/lido-mmd-production-readiness.md new file mode 100644 index 0000000000..06955dc025 --- /dev/null +++ b/docs/lido-mmd-production-readiness.md @@ -0,0 +1,95 @@ +# Lido Money Machine — production readiness checklist + +The MVP shipped on `money-machine-dashboard` (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 Caddy | 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. | + +## 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..6038358dfd --- /dev/null +++ b/docs/lido-mmd-status.md @@ -0,0 +1,61 @@ +# Lido MMD demo — live status + +> Living doc for the `money-machine-dashboard` branch. 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-machine-dashboard` | UI + demo override layer | +| `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, caddy), Caddyfile, init-demo.sh, vm-README.md | demo-infra | **done** | `infra/lmm-demo/vm/`. Persistent anvil via `--state`, caddy serves manifest from shared volume. | +| `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`). | + +## 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-machine-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/README.md b/infra/lmm-demo/README.md new file mode 100644 index 0000000000..5af03d93b3 --- /dev/null +++ b/infra/lmm-demo/README.md @@ -0,0 +1,219 @@ +# 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 + Caddy and the app + reads from `https://` via the Vercel preview URL. See + [`vm/vm-README.md`](./vm/vm-README.md). + +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. + +--- + +## TL;DR + +```bash +# In dao-launchpad@f/lido-demo +just anvil # terminal 1: anvil mainnet fork on :8545 +just demo-up # terminal 2: deploys CR slice + LMM DAO + +# In capital-flow-indexer (this repo's sibling) +cp .env.example .env # set ENVIO_RPC_URL=http://localhost:8545 +pnpm envio dev # terminal 3: indexer + hasura on :8080 + +# In app/ +cp .env.example .env.local +echo 'NEXT_PUBLIC_LMM_DEMO_MODE=1' >> .env.local +echo 'NEXT_PUBLIC_LMM_MANIFEST_URL=/lmm-manifest.json' >> .env.local +echo 'NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=http://localhost:8080/v1/graphql' >> .env.local +echo 'NEXT_PUBLIC_LMM_RPC_URL=http://localhost:8545' >> .env.local + +# Symlink the just-generated manifest into /public so the app can fetch it. +ln -sf ../../dao-launchpad/lido/preview/script/demo/manifest.json \ + public/lmm-manifest.json + +pnpm dev # terminal 4: open /dao/ethereum-mainnet//flow +``` + +The DAO address to navigate to is `manifest.lmm.dao` from the generated +`manifest.json`. + +--- + +## Local quickstart + +### Prerequisites + +* `pnpm` 9+, Node.js 20+ +* `foundry` (anvil, cast, forge) — `curl -L https://foundry.paradigm.xyz | bash` +* `just` +* A mainnet archive RPC URL for the Anvil fork (Alchemy / Infura). + Export it as `MAINNET_RPC=https://...`. +* The three sibling repos checked out alongside `app/`: + * `dao-launchpad` on branch `f/lido-demo` + * `capital-flow-indexer` (this branch) + +### 1. Start Anvil + deploy the demo + +```bash +cd dao-launchpad +git checkout f/lido-demo +cd lido/preview +just demo-up # boots anvil, deploys CR + LMM DAO +``` + +`just demo-up` is idempotent — it tears down any previous fork before +deploying. The final step writes `script/demo/manifest.json` with all +the addresses the UI needs. + +### 2. Start the indexer + +```bash +cd ../../../capital-flow-indexer +cp .env.example .env +# Edit .env: +# ENVIO_RPC_URL=http://localhost:8545 +# ENVIO_CHAIN_ID=1 +# ENVIO_START_BLOCK=0 +pnpm install +pnpm envio dev # serves GraphQL on http://localhost:8080 +``` + +The indexer auto-discovers the demo's contracts via `DAOFactory.DAOCreated` +and `PluginSetupProcessor.InstallationPrepared` — no need to plug +addresses in by hand. + +### 3. Run the app + +```bash +cd ../app +pnpm install +cp .env.example .env.local +``` + +Append the LMM demo flags to `.env.local`: + +``` +NEXT_PUBLIC_LMM_DEMO_MODE=1 +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: + +```bash +ln -sf ../../dao-launchpad/lido/preview/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. + +### 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`, etc. — + defined in `dao-launchpad/lido/preview/justfile`. + +--- + +## 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` (or unset) in `.env.local`. +* **"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..670fe93b1c --- /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's caddy. Replace `` with the +# actual host (e.g. lmm-demo.aragon-team.xyz). +NEXT_PUBLIC_LMM_MANIFEST_URL=https:///manifest.json + +# Envio Hasura GraphQL endpoint proxied by caddy. +NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=https:///graphql + +# Anvil RPC proxied by caddy. Adding the hostname to LMM_RPC_ALLOWLIST in +# `src/modules/flow/demo/lmmDemoConfig.ts` is required — `assertForkRpc` +# blocks writes against any host outside the allowlist. +NEXT_PUBLIC_LMM_RPC_URL=https:///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..238e9aba68 --- /dev/null +++ b/infra/lmm-demo/vm/.env.vm.example @@ -0,0 +1,26 @@ +# Copy to .env, fill in values, then `docker compose up -d`. + +# Public DNS that points at this VM. Caddy will provision a Let's Encrypt +# cert for this hostname automatically. +DOMAIN=lmm-demo.aragon-team.xyz + +# 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 inside Hasura — this only protects the admin console. +HASURA_ADMIN_SECRET=changeme + +# 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/Caddyfile b/infra/lmm-demo/vm/Caddyfile new file mode 100644 index 0000000000..1f4c025926 --- /dev/null +++ b/infra/lmm-demo/vm/Caddyfile @@ -0,0 +1,71 @@ +# Lido Money Machine demo — Caddy v2 config +# +# Exposes three public endpoints over TLS with CORS:* so the Vercel-hosted +# Aragon app can talk to them from any preview origin: +# +# https://$DOMAIN/rpc → anvil:8545 (JSON-RPC for reads + anvil-write actions) +# https://$DOMAIN/graphql → hasura:8080 (Envio's Hasura GraphQL endpoint, public role) +# https://$DOMAIN/manifest.json → file_server /srv/lmm/manifest.json (deployment addresses) +# +# CORS is wide-open intentionally — this is throw-away demo infra, not prod. +# +# Caddy auto-provisions a Let's Encrypt cert for $DOMAIN. + +{$DOMAIN} { + encode zstd gzip + + # Shared CORS handler — applied to every route below. + @cors_preflight method OPTIONS + handle @cors_preflight { + header { + Access-Control-Allow-Origin "*" + Access-Control-Allow-Methods "GET, POST, OPTIONS" + Access-Control-Allow-Headers "Content-Type, Authorization, X-Demo-Key" + Access-Control-Max-Age "86400" + } + respond "" 204 + } + + header { + Access-Control-Allow-Origin "*" + Access-Control-Allow-Methods "GET, POST, OPTIONS" + Access-Control-Allow-Headers "Content-Type, Authorization, X-Demo-Key" + # Stop browsers from caching the manifest across redeploys. + Cache-Control "no-store" + } + + # JSON-RPC: forwarded to anvil. POST-only in practice; OPTIONS handled above. + handle /rpc* { + reverse_proxy anvil:8545 + } + + # GraphQL: forwarded to Hasura. Public role is enabled in compose, + # so anonymous queries work; mutations require admin secret which + # we don't expose externally. + handle /graphql* { + uri strip_prefix /graphql + rewrite * /v1/graphql{uri} + reverse_proxy hasura:8080 + } + + # Manifest: served straight off the anvil volume (written by init-demo.sh). + handle /manifest.json { + root * /srv/lmm + file_server + } + + # Healthcheck. + handle /healthz { + respond "ok" 200 + } + + # Everything else: 404 (we don't expose anvil/hasura/postgres consoles publicly). + handle { + respond "Not Found" 404 + } + + log { + output stdout + format console + } +} diff --git a/infra/lmm-demo/vm/docker-compose.yml b/infra/lmm-demo/vm/docker-compose.yml new file mode 100644 index 0000000000..7abbf5bf0d --- /dev/null +++ b/infra/lmm-demo/vm/docker-compose.yml @@ -0,0 +1,130 @@ +# Lido Money Machine demo — VM stack +# +# Five services: anvil (mainnet fork), postgres + envio + hasura (indexer), +# caddy (TLS + CORS proxy). All ports are internal except caddy:443. +# +# Designed for a single VM (Hetzner CX22, DO 2vCPU/4GB or similar). +# +# Bring up: +# cp .env.vm.example .env +# # edit .env: MAINNET_RPC, DOMAIN, optionally FORK_BLOCK +# docker compose up -d +# ./init-demo.sh # one-shot: clones dao-launchpad@f/lido-demo, runs just demo-up +# +# Tear down: +# docker compose down -v # WARNING: drops anvil state + indexer DB +# +# Daily ops: +# docker compose logs -f anvil # follow fork +# docker compose exec anvil cast block latest --rpc-url http://localhost:8545 +# curl https://$DOMAIN/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). + 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" + volumes: + - lmm-data:/data + expose: + - "8545" + + postgres: + image: postgres:17-alpine + restart: unless-stopped + 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 + 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 + ENVIO_DEMO_RPC_URL: http://anvil:8545 + 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 + 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" + # The console is open without auth on the VM — caddy will proxy /graphql + # publicly (we want unauthenticated reads), but the admin UI should be + # protected. Set HASURA_GRAPHQL_ADMIN_SECRET via .env for that. + HASURA_GRAPHQL_ADMIN_SECRET: ${HASURA_ADMIN_SECRET:-changeme} + HASURA_GRAPHQL_UNAUTHORIZED_ROLE: "public" + expose: + - "8080" + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - anvil + - hasura + ports: + - "80:80" + - "443:443" + environment: + DOMAIN: ${DOMAIN} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + # Manifest is written by init-demo.sh into the anvil volume; caddy serves it static. + - lmm-data:/srv/lmm:ro + +volumes: + lmm-data: + lmm-pg: + caddy-data: + caddy-config: diff --git a/infra/lmm-demo/vm/init-demo.sh b/infra/lmm-demo/vm/init-demo.sh new file mode 100755 index 0000000000..06e4b5b5bc --- /dev/null +++ b/infra/lmm-demo/vm/init-demo.sh @@ -0,0 +1,102 @@ +#!/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 + caddy. +# +# Steps: +# 1. Wait for anvil to be ready (eth_blockNumber). +# 2. If LMM DAO is already deployed (PrepareDemo writes a marker), skip. +# 3. Clone dao-launchpad@f/lido-demo if missing. +# 4. Run `just demo-up` against anvil (deploys CR slice + LMM + mocks + seed). +# 5. Copy the resulting script/demo/manifest.json into the anvil volume +# so caddy can serve it as https://$DOMAIN/manifest.json. +# +# Usage: +# DOMAIN=lmm-demo.aragon-team.xyz MAINNET_RPC=... ./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://localhost:8545}" +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 <=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/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx b/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx index 01ae1d2b9e..edefeb3864 100644 --- a/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx +++ b/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx @@ -2,6 +2,10 @@ import { invariant } from '@aragon/gov-ui-kit'; import { useRouter } from 'next/navigation'; 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 { useIsLmmDemoDao } from '@/modules/flow/demo/lmmDemoConfig'; import type { Network } from '@/shared/api/daoService'; import type { IDaoPolicy } from '@/shared/api/daoService/domain/daoPolicy'; import { @@ -17,6 +21,7 @@ import { useTranslations } from '@/shared/components/translationsProvider'; import { useStepper } from '@/shared/hooks/useStepper'; import { CapitalFlowDialogId } from '../../constants/capitalFlowDialogId'; import type { IRouterSelectorDialogParams } from '../routerSelectorDialog'; +import { LmmDemoDispatchDialog } from './lmmDemoDispatchDialog'; export interface IDispatchTransactionDialogParams { policy: IDaoPolicy; @@ -60,6 +65,28 @@ 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 — in that case we proceed with the production + // wagmi flow rather than block the dialog. All hooks below this branch + // are inside `ProductionDispatchDialog` to keep the hook order stable. + const isLmmDemo = useIsLmmDemoDao(policy.daoAddress); + if (isLmmDemo === true) { + return ; + } + return ; +}; + +const ProductionDispatchDialog: React.FC = ( + props, +) => { + const { location } = props; invariant( location.params != null, 'DispatchTransactionDialog: required parameters must be set.', diff --git a/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx b/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx new file mode 100644 index 0000000000..be016057a4 --- /dev/null +++ b/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx @@ -0,0 +1,255 @@ +// LMM_DEMO_HACK: stand-in for `DispatchTransactionDialog` when the policy +// belongs to the Lido Money Machine demo DAO. Instead of going through wagmi +// (which would prompt MetaMask + sign on whatever chain the user is connected +// to), we drive `dispatch()` directly against the Anvil fork via viem + +// `--auto-impersonate`. See `infra/lmm-demo/README.md` for the threat model. + +import { Dialog } from '@aragon/gov-ui-kit'; +import { useRouter } from 'next/navigation'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + type Address, + createPublicClient, + type Hex, + http, + type PublicClient, +} from 'viem'; +import { mainnet } from 'viem/chains'; +import { + type ActionContext, + deriveAddressesFromManifest, + dispatchAction, +} from '@/modules/flow/components/lidoMoneyMachine/actions'; +import { StepsView } from '@/modules/flow/components/lidoMoneyMachine/StepsView'; +import { + LMM_DEMO_MODE, + LMM_RPC_URL, + useLmmManifest, +} from '@/modules/flow/demo/lmmDemoConfig'; +import { assertForkRpc } from '@/modules/flow/demo/safety'; +import { useDialogContext } from '@/shared/components/dialogProvider'; +import { + type FlowGraph, + inspect, + simulate, + type TopologyGraph, +} from '@/shared/lidoPreview'; +import { CapitalFlowDialogId } from '../../constants/capitalFlowDialogId'; +import type { + IDispatchTransactionDialogParams, + IDispatchTransactionDialogProps, +} from './dispatchTransactionDialog'; + +/** + * Lightweight viem PublicClient cache so we don't churn instances across + * remounts of the dialog (each instance opens its own websocket otherwise). + */ +let cachedClient: PublicClient | undefined; +const getPublicClient = (): PublicClient => { + 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.dispatcher 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 ( + <> + + +
    +
    + Demo mode · all writes go to {LMM_RPC_URL}. Real chains + are not touched. +
    + + {phase === 'preparing' && ( +
    + Computing the next dispatch… +
    + )} + {simError && ( +
    + Simulation failed: {simError} +
    + Dispatch may still succeed on chain, but the + preview can't show what'll happen. +
    +
    + )} + {flow && ( +
    + +
    + )} + {txError && ( +
    + Dispatch failed: {txError} +
    + )} + {phase === 'success' && txHash && ( +
    + Dispatch confirmed. Tx hash: {txHash.slice(0, 10)}… +
    + )} +
    +
    + + + ); +}; diff --git a/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx b/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx index 8f30cebae9..fb3e5ec1ff 100644 --- a/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx +++ b/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx @@ -5,6 +5,7 @@ import classNames from 'classnames'; import Link from 'next/link'; import { useFlowDataContext } from '../../providers/flowDataProvider'; import type { + IFlowEmbeddedStrategy, IFlowOrchestrator, IFlowOrchestratorLeg, IFlowOrchestratorRun, @@ -55,7 +56,9 @@ export const FlowMultiDispatchCard: React.FC = ( const pendingDispatch = getPendingDispatch(orchestrator.id); const isDispatching = pendingDispatch != null; const isArchived = orchestrator.status === 'paused'; - const hasChain = orchestrator.chain.some((child) => child != null); + const hasEmbedded = (orchestrator.embeddedStrategies?.length ?? 0) > 0; + const hasChain = + hasEmbedded || orchestrator.chain.some((child) => child != null); const handleDispatch = () => { dispatchPolicy(orchestrator.id); @@ -111,11 +114,17 @@ export const FlowMultiDispatchCard: React.FC = (
    - + {hasEmbedded ? ( + + ) : ( + + )}
    @@ -277,6 +286,119 @@ const ChainArrow: React.FC = () => ( ); +// --------------------------------------------------------------------------- +// Embedded-strategy chain (Dispatcher-owned strategies) +// --------------------------------------------------------------------------- + +interface IFlowEmbeddedChainDiagramProps { + strategies: IFlowEmbeddedStrategy[]; +} + +/** + * 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, +}) => ( +
    + {strategies.map((s, index) => ( + + ))} +
    +); + +const STRATEGY_KIND_TONE: Record = { + wrap: 'border-primary-200 bg-primary-50', + univ2Liquidity: 'border-violet-200 bg-violet-50', + gatedCowSwap: 'border-cyan-200 bg-cyan-50', + cowSwap: 'border-cyan-200 bg-cyan-50', + transfer: 'border-neutral-200 bg-neutral-0', + epochTransfer: 'border-warning-200 bg-warning-50', + burn: 'border-critical-200 bg-critical-50', + unknown: 'border-neutral-200 bg-neutral-0', +}; + +const EmbeddedStrategyChip: React.FC<{ + strategy: IFlowEmbeddedStrategy; + showArrow: boolean; +}> = ({ strategy, showArrow }) => { + const seconds = strategy.epochProvider?.epochLength; + const epochLabel = formatEpochLength(seconds); + 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) // --------------------------------------------------------------------------- @@ -319,13 +441,18 @@ interface IRunLegProps { 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 (
  • = { dispatchFailed: FLOW_FAILED_COLOR, }; -export type FlowMarkerKind = 'dispatchOk' | 'dispatchFailed' | FlowEventKind; +export type FlowMarkerKind = + | 'dispatchOk' + | 'dispatchFailed' + | 'dispatchSkipped' + | 'dispatchSwap' + | FlowEventKind; export interface IFlowTimelineMarker { id: string; @@ -58,5 +71,11 @@ export const getFlowMarkerColor = ( 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 index 4e56729a91..02fc014286 100644 --- a/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx +++ b/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx @@ -24,6 +24,8 @@ import { FLOW_EVENT_KIND_LABEL, FLOW_EVENT_KIND_TONE, FLOW_FAILED_COLOR, + FLOW_SKIPPED_COLOR, + FLOW_SWAP_COLOR, getFlowMarkerColor, type IFlowTimelineMarker, } from './flowEventStyles'; @@ -89,7 +91,10 @@ export const FlowPolicyChart: React.FC = (props) => { for (const dispatch of historicSorted) { const ts = new Date(dispatch.at).getTime(); - if (dispatch.status !== 'failed') { + // Only successful dispatches contribute to the cumulative curve — + // both `failed` and `skipped` outcomes show up as timeline markers + // but leave the running total untouched. + if (dispatch.status !== 'failed' && dispatch.status !== 'skipped') { cumulative += dispatch.amount; } points.push({ timestamp: ts, historic: cumulative }); @@ -99,23 +104,48 @@ export const FlowPolicyChart: React.FC = (props) => { points.push({ timestamp: endTs, historic: historicEnd }); const dispatchMarkers: IFlowTimelineMarker[] = historicSorted.map( - (dispatch) => ({ - id: `dispatch-${dispatch.id}`, - timestamp: new Date(dispatch.at).getTime(), - kind: - dispatch.status === 'failed' - ? 'dispatchFailed' - : 'dispatchOk', - label: - dispatch.status === 'failed' - ? 'Failed dispatch' - : 'Dispatch', - detail: `${formatFlowAmount(dispatch.amount, dispatch.token)} ${dispatch.token} · ${formatShortDate(dispatch.at)}${ - dispatch.status === 'failed' && dispatch.failureReason - ? ` · ${dispatch.failureReason}` - : '' - }`, - }), + (dispatch) => { + const isSwap = + dispatch.tokenIn != null && dispatch.amountIn != null; + 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 (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 (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 @@ -148,7 +178,18 @@ export const FlowPolicyChart: React.FC = (props) => { const hasFailedDispatch = policy.dispatches.some( (d) => d.status === 'failed', ); - const hasOkDispatch = 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 (
    = (props) => { dim={!hasFailedDispatch} label="Failed dispatch" /> + + {( [ 'policyInstalled', @@ -288,7 +339,8 @@ export const FlowPolicyChart: React.FC = (props) => {

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

    ); diff --git a/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx b/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx index 4b4e2bbc90..e734ab7bc2 100644 --- a/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx +++ b/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx @@ -1,10 +1,16 @@ 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, useLmmManifest } from '../../demo/lmmDemoConfig'; import type { IFlowPolicy, IFlowPolicySubRouter, IFlowRecipient, } from '../../types'; +import { LmmPolicyTopology } from '../lidoMoneyMachine/LmmPolicyTopology'; export interface IFlowPolicyTreeProps { policy: IFlowPolicy; @@ -171,6 +177,47 @@ const nodeTypeLabel: Record = { export const FlowPolicyTree: React.FC = (props) => { const { policy, 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.dispatcher.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); 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..8c4708be6c --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx @@ -0,0 +1,174 @@ +// 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, + useLmmManifest, +} from '@/modules/flow/demo/lmmDemoConfig'; +import { assertForkRpc } from '@/modules/flow/demo/safety'; +import { type ActionItem, ActionsMenu } from './ActionsMenu'; +import { + type ActionContext, + deriveAddressesFromManifest, + dispatchAction, + setEthPrice, + setTargetEpoch, + settleCowSwap, + 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.dispatcher, + 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.', + run: runAction('warp-1d', () => warpAction(ctx, 24 * 60 * 60)), + }, + { + key: 'warp-7d', + label: 'Warp +7 days', + description: 'Advance the fork clock by 7 days.', + run: runAction('warp-7d', () => + warpAction(ctx, 7 * 24 * 60 * 60), + ), + }, + ], + [ + { + 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: + 'Simulate the settlement contract executing the pending order.', + run: runAction('settle-cowswap', () => + settleCowSwap(ctx, parseEther('1'), parseEther('3000')), + ), + }, + ], + ]; + + 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..4d3b37d2e9 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx @@ -0,0 +1,113 @@ +// LMM_DEMO_HACK: client-side wrapper that runs the vendored preview-lib +// `inspect()` against the demo anvil fork and renders the result via the +// vendored React Flow TopologyView. Production builds NEVER mount this: +// the FlowPolicyTree route guards on isLmmDemoDao() before importing. + +'use client'; + +import { useEffect, useState } from 'react'; +import { + type Address, + createPublicClient, + http, + type PublicClient, +} from 'viem'; +import { mainnet } from 'viem/chains'; +import { LMM_RPC_URL } from '@/modules/flow/demo/lmmDemoConfig'; +import { inspect, type TopologyGraph } from '@/shared/lidoPreview'; +import { TopologyView } from './TopologyView'; +import { useStatus } from './useStatus'; + +interface ILmmPolicyTopologyProps { + /** DispatcherPlugin address (i.e. the top-level multi-dispatch policy). */ + pluginAddress: Address; + /** Optional Lido DAO address rendered as a parent node above the LMM DAO. */ + lidoDaoAddress?: string; + className?: string; +} + +// 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; +}; + +export const LmmPolicyTopology: React.FC = (props) => { + const { pluginAddress, lidoDaoAddress, className } = props; + const [topology, setTopology] = useState( + undefined, + ); + const [error, setError] = useState(undefined); + + useEffect(() => { + let cancelled = false; + const run = async () => { + try { + const client = getClient(); + const result = await inspect(client, pluginAddress); + if (!cancelled) { + setTopology(result); + } + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e : new Error(String(e))); + } + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [pluginAddress]); + + // Live status (budget amounts, paused flags, gate readings) — polled by + // the vendored useStatus hook against the same RPC. useStatus expects a + // client factory (it constructs its own retries) and a nullable topology. + const { state: statusState } = useStatus(getClient, topology ?? null); + const statusSnapshot = + statusState.kind === 'ready' ? statusState.snapshot : undefined; + + if (error) { + return ( +
    +
    + Topology inspection failed: {error.message} +
    +
    + ); + } + + if (!topology) { + return ( +
    +
    + Loading topology from {LMM_RPC_URL}… +
    +
    + ); + } + + return ( +
    + +
    + ); +}; 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..051d01e76d --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/StatusPanel.tsx @@ -0,0 +1,77 @@ +// 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. + +// Bottom panel — now Status-only. Status auto-refreshes via `useStatus`; +// this component just provides the resizable chrome + the StatusView render +// surface. No outer tabs (the Simulation pane was demoted to a confirm +// modal), no close button (status is part of the page, not optional UI). + +import { useEffect, useRef, useState } from 'react'; +import { StatusView } from './StatusView'; +import type { StatusState } from './useStatus'; + +const MIN_HEIGHT = 180; +const DEFAULT_HEIGHT = 360; + +export function StatusPanel({ + state, + onRefresh, +}: { + state: StatusState; + onRefresh: () => void; +}) { + const [height, setHeight] = useState(DEFAULT_HEIGHT); + const [dragging, setDragging] = useState(false); + const dragRef = useRef({ startY: 0, startHeight: 0 }); + + useEffect(() => { + if (!dragging) { + return; + } + function onMove(e: MouseEvent) { + const delta = dragRef.current.startY - e.clientY; + const max = Math.max(MIN_HEIGHT, window.innerHeight - 120); + const next = Math.max( + MIN_HEIGHT, + Math.min(max, dragRef.current.startHeight + delta), + ); + setHeight(next); + } + function onUp() { + setDragging(false); + } + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + const prevCursor = document.body.style.cursor; + const prevSelect = document.body.style.userSelect; + document.body.style.cursor = 'ns-resize'; + document.body.style.userSelect = 'none'; + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + document.body.style.cursor = prevCursor; + document.body.style.userSelect = prevSelect; + }; + }, [dragging]); + + function onResizeStart(e: React.MouseEvent) { + e.preventDefault(); + dragRef.current = { startY: e.clientY, startHeight: height }; + setDragging(true); + } + + return ( +
    +
    + +
    + ); +} diff --git a/src/modules/flow/components/lidoMoneyMachine/StatusView.tsx b/src/modules/flow/components/lidoMoneyMachine/StatusView.tsx new file mode 100644 index 0000000000..769974974b --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/StatusView.tsx @@ -0,0 +1,381 @@ +// 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 view. Mirrors `just demo-status` but grouped into cards and +// without the awk-formatted ASCII boxes. Data comes from `useStatus` — +// every value is a fresh on-chain read keyed by the inspected topology, +// plus a `simulate()` result that fuels the "next dispatch" card. + +import type { ReactNode } from 'react'; +import { formatAmount, shortAddress } from './format'; +import type { StatusSnapshot, StatusState } from './useStatus'; + +export function StatusView({ + state, + onRefresh, +}: { + state: StatusState; + onRefresh: () => void; +}) { + if (state.kind === 'idle') { + return
    Inspecting…
    ; + } + if (state.kind === 'loading') { + return
    Loading live state…
    ; + } + if (state.kind === 'error') { + return ( +
    + Status fetch failed: {state.message} +
    + +
    +
    + ); + } + + const snap = state.snapshot; + return ( +
    +
    + + @ block {snap.block.toString()} · LMM DAO{' '} + {shortAddress(snap.dao)} + {state.refreshing && ( + + {' '} + · refreshing… + + )} + {state.refreshError && ( + + {' '} + · refresh failed + + )} + +
    +
    + + {snap.lp && } + + {snap.stream && } + {snap.gate && } + {snap.cowSwap && } +
    +
    + ); +} + +// --- Cards ----------------------------------------------------------------- + +function Card({ + title, + hint, + children, +}: { + title: string; + hint?: string; + children: ReactNode; +}) { + return ( +
    +
    +

    {title}

    + {hint && {hint}} +
    + {children} +
    + ); +} + +function LmmDaoCard({ snap }: { snap: StatusSnapshot }) { + return ( + +
    + {snap.balances.map((b) => ( + + } + /> + ))} +
    +
    + ); +} + +// The LP recipient on the demo deployment is the Lido Agent, so the LP +// tokens minted by UniV2 land in the Lido DAO's vault — not in the LMM. +// Surfacing them under a separate card keeps the LMM card honest about +// what the LMM actually holds. +function LidoDaoCard({ snap }: { snap: StatusSnapshot }) { + const lp = snap.lp!; + return ( + + {lp.pair ? ( +
    + + } + /> +
    + ) : ( +

    + Pair not deployed yet — the strategy needs the pair to + exist. +

    + )} +
    + ); +} + +function BudgetsCard({ snap }: { snap: StatusSnapshot }) { + return ( + +
    + {snap.budgets.map((b, i) => ( + + {b.label}{' '} + + ({budgetKindShort(b.budgetKind)}) + + + } + key={`${b.strategyAddress}-${i}`} + v={ + + } + /> + ))} +
    +
    + ); +} + +function StreamCard({ snap }: { snap: StatusSnapshot }) { + const s = snap.stream!; + return ( + +
    + + {s.currentEpoch.toString()}} + /> + {s.targetEpoch.toString()}} + /> + + {s.remaining.toString()} ep{' '} + + {s.remaining < BigInt(s.floorEpochs) + ? '(below floor → constant drain)' + : '(above floor → drain grows as epochs tick)'} + + + } + /> + {s.floorEpochs} ep} /> +
    +
    + ); +} + +function GateCard({ snap }: { snap: StatusSnapshot }) { + const g = snap.gate!; + // Staleness is decided by the gate against `block.timestamp`, not the + // host wallclock — after a Warp the two diverge, and using Date.now() + // here would tell the user "fresh" while the gate actually fails closed. + const age = + g.updatedAt !== null + ? Math.max(0, Number(snap.blockTimestamp - g.updatedAt)) + : null; + const stale = + age !== null && g.maxStaleness > 0n && BigInt(age) > g.maxStaleness; + // The oracle returns the price scaled to tokenB's decimals (USDC = 6 in + // the demo: 3_000_000_000 reads "$3000.00"). We don't fetch TokenInfo + // for tokenB at inspect time, so hardcode 6 here — TODO: generalise via + // `erc20.decimals()` if a non-USDC pair ever ships. + const PRICE_DECIMALS = 6; + return ( + +
    + + {g.passes ? 'open' : 'closed'} + + } + /> + + + {formatAmount(g.price, PRICE_DECIMALS)} + {' '} + + threshold{' '} + + {formatAmount( + g.threshold, + PRICE_DECIMALS, + )} + + + + ) : ( + unreadable + ) + } + /> + + {age}s ago{' '} + + {stale + ? '(stale — gate fails closed)' + : `(stale at ${g.maxStaleness.toString()}s)`} + + + ) : ( + + ) + } + /> +
    +
    + ); +} + +function CowSwapCard({ snap }: { snap: StatusSnapshot }) { + const cs = snap.cowSwap!; + return ( + +
    + {cs.ordersPlaced.toString()} + ) : ( + + ) + } + /> + + } + /> +
    +
    + ); +} + +// --- Small UI atoms -------------------------------------------------------- + +function Row({ k, v }: { k: ReactNode; v: ReactNode }) { + return ( + <> +
    {k}
    +
    {v}
    + + ); +} + +function Amount({ + amount, + decimals, + token, + muted, +}: { + amount: bigint; + decimals: number | null; + token: string | null; + muted?: boolean; +}) { + return ( + + {formatAmount(amount, decimals, 4)} + {token && {token}} + + ); +} + +function Mono({ children }: { children: ReactNode }) { + return {children}; +} + +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..f412f187e6 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx @@ -0,0 +1,421 @@ +// 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; +} + +function gateStateLabel( + status: StatusSnapshot | undefined, +): string | undefined { + if (!status?.gate) { + return undefined; + } + return `gate ${status.gate.passes ? 'open' : 'closed'}`; +} + +/** 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, +}: { + 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; +}) { + const [selected, setSelected] = useState(null); + + const { nodes, edges, byId } = 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); + 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]); + + // Close any open details when the topology changes. + useEffect(() => { + setSelected(null); + }, [topology]); + + 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..dca05b7554 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/actions.ts @@ -0,0 +1,356 @@ +// 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. +const ANVIL_DEPLOYER_KEY: Hex = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +const ERC20_TRANSFER_ABI = parseAbi([ + 'function transfer(address,uint256) returns (bool)', +]); + +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, + }); +} + +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, + ], + }); +} + +// ---- 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..e957d59b74 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/layout.ts @@ -0,0 +1,47 @@ +// 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 dagre from '@dagrejs/dagre'; +import type { ReactFlowGraph } from '@/shared/lidoPreview'; + +const NODE_WIDTH = 200; +const NODE_HEIGHT = 72; + +/** + * Top-to-bottom dagre layout. React Flow's default node handles are at + * top/bottom edges, so a TB layout lines them up without needing custom + * node components. + * + * React Flow positions by top-left; dagre by node center — we subtract + * half the size to align. + */ +export function layout(graph: ReactFlowGraph): ReactFlowGraph { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: 'TB', nodesep: 40, ranksep: 100 }); + + 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); + + 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..c4c1a9c8f3 --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/styles.css @@ -0,0 +1,1472 @@ +/* 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 --------------------------------------------------------- */ +.status { + padding: 12px 16px 16px; + overflow: auto; + height: 100%; +} +.status-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + font-family: ui-monospace, monospace; + font-size: 12px; + color: #475569; +} +.status-toolbar button { + padding: 4px 10px; + font-size: 12px; +} +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; +} +.status-card { + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #ffffff; + overflow: hidden; +} +.status-card header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid #e2e8f0; +} +/* Soft per-concern tints — pick the matching family the topology nodes + already use, then drop to a low-saturation tint so the card reads as + "kind of this". Order in .status-grid is stable, so card-N selectors + work even without per-card classes. */ +.status-card:nth-child(1) { + background: #fefce8; +} /* Balances — yellow family */ +.status-card:nth-child(1) header { + background: #fdfdb8; +} +.status-card:nth-child(2) { + background: #ecfeff; +} /* Budgets — teal family (matches Lido strategy nodes) */ +.status-card:nth-child(2) header { + background: #cffafe; +} +.status-card:nth-child(3) { + background: #ecfeff; +} /* Stream — same teal as Budgets, it's the wstETH/stream lane */ +.status-card:nth-child(3) header { + background: #cffafe; +} +.status-card:nth-child(4) { + background: #fdf2f8; +} /* Gate — pink family (matches PriceFloorGate node) */ +.status-card:nth-child(4) header { + background: #fce7f3; +} +.status-card:nth-child(5) { + background: #faf5ff; +} /* CowSwap — purple family */ +.status-card:nth-child(5) header { + background: #f3e8ff; +} +.status-card:nth-child(6) { + background: #f0fdf4; +} /* Next dispatch — green family */ +.status-card:nth-child(6) header { + background: #dcfce7; +} +.status-card h4 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: #0f172a; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.status-hint { + font-size: 11px; + color: #64748b; + font-family: ui-monospace, monospace; +} +.status-card .kv { + margin: 0; + padding: 10px 12px; + display: grid; + grid-template-columns: minmax(110px, max-content) 1fr; + column-gap: 12px; + row-gap: 6px; + font-size: 13px; + align-items: baseline; +} +.status-card dt { + color: #475569; +} +.status-card dd { + margin: 0; + color: #0f172a; + text-align: right; +} +.status-card .muted { + color: #94a3b8; +} +.status-card .small { + font-size: 11px; +} +.status-card .mono { + font-family: ui-monospace, monospace; +} +.status-card .warn { + color: #b45309; +} +.status-card p { + margin: 0; + padding: 12px; +} + +/* Pills (gate open/closed, would-fire / skip). */ +.pill, +.pill-open, +.pill-closed { + display: inline-block; + padding: 1px 8px; + font-size: 11px; + font-weight: 600; + border-radius: 999px; + text-transform: lowercase; + letter-spacing: 0.02em; +} +.pill-open { + background: #dcfce7; + color: #166534; +} +.pill-closed { + background: #fee2e2; + color: #991b1b; +} +.pill-opaque { + background: #e2e8f0; + color: #475569; +} + +/* Balances card sub-sections (DAO vs LP). */ +.balances-group + .balances-group { + border-top: 1px dashed #e2e8f0; +} +.balances-section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #475569; + padding: 8px 12px 0; +} +.balances-empty { + padding: 6px 12px 12px !important; + font-size: 12px; +} + +/* Next-dispatch step cards (inside the Status pane). */ +.status-steps { + list-style: none; + margin: 0; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} +.status-step { + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #fafafa; + padding: 6px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} +.status-step-executed { + background: #f0fdf4; + border-color: #bbf7d0; +} +.status-step-no-op, +.status-step-skipped-paused { + background: #fff7ed; + border-color: #fed7aa; +} +.status-step-opaque, +.status-step-downstream-opaque { + background: #f1f5f9; + border-color: #cbd5e1; +} +.status-step-head { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +.status-step-head strong { + color: #0f172a; +} +.status-step-effects { + display: flex; + flex-direction: column; + gap: 2px; + padding-left: 8px; + font-family: ui-monospace, monospace; + font-size: 12px; +} +.effect { + color: #334155; +} +.effect-consume { + color: #9a3412; +} +.effect-produce { + color: #047857; +} +.effect-call { + color: #475569; + font-style: italic; +} +.status-step-reason { + padding-left: 8px; +} +.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/demo/LmmDemoBanner.tsx b/src/modules/flow/demo/LmmDemoBanner.tsx new file mode 100644 index 0000000000..98e7fd2073 --- /dev/null +++ b/src/modules/flow/demo/LmmDemoBanner.tsx @@ -0,0 +1,48 @@ +// 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 { LMM_DEMO_MODE, LMM_RPC_URL, useLmmManifest } from './lmmDemoConfig'; + +export const LmmDemoBanner: React.FC<{ className?: string }> = (props) => { + const { className } = props; + const { manifest, error } = useLmmManifest(); + 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.dispatcher.slice(0, 6)}… + {manifest.lmm.dispatcher.slice(-4)} + + )} + {error && ( + + Manifest failed to load: {error.message} + + )} +
    +
    + ); +}; diff --git a/src/modules/flow/demo/lmmDaoOverride.ts b/src/modules/flow/demo/lmmDaoOverride.ts new file mode 100644 index 0000000000..987e34e848 --- /dev/null +++ b/src/modules/flow/demo/lmmDaoOverride.ts @@ -0,0 +1,323 @@ +// 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 { + 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; + +const loadManifest = (): Promise => { + if (cachedManifest) { + return Promise.resolve(cachedManifest); + } + if (inflightManifest) { + return inflightManifest; + } + inflightManifest = fetch(LMM_MANIFEST_URL, { cache: 'no-store' }) + .then((r) => { + if (!r.ok) { + throw new Error( + `manifest fetch ${r.status} from ${LMM_MANIFEST_URL}`, + ); + } + return r.json() as Promise; + }) + .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, + description: envio?.description ?? manifest.metadata.description, + ens: null, + subdomain: null, + avatar: envio?.avatarUrl ?? manifest.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 ?? []).map((l) => ({ + name: l.label, + url: l.url, + })), + 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.dispatcher, + 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.dispatcher, + daoAddress: manifest.lmm.dao, + interfaceType: PolicyInterfaceType.ROUTER, + strategy: { + type: PolicyStrategyType.MULTI_DISPATCH, + subRouters: manifest.lmm.strategies.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 as an ENS-ish slug (lowercased) so URLs like + // ///* resolve to the demo DAO. + const slug = manifest.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 lmmDemoConfig.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..24c6af529c --- /dev/null +++ b/src/modules/flow/demo/lmmDemoConfig.ts @@ -0,0 +1,226 @@ +// LMM_DEMO_HACK: demo-mode flags + manifest loader for the Lido Money Machine +// demo. Everything in this file is a *temporary* short-circuit around the +// production Aragon backend / wagmi pipeline; remove the imports of this +// module to drop demo mode entirely. +// +// See `docs/lido-mmd-production-readiness.md` for the removal checklist. + +import { useEffect, useState } from 'react'; +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'; + +/** + * 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 caddy at `https:///manifest.json`. 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', + // Production demo VM — adjust per environment. + 'lmm-demo.aragon-team.xyz', +]; + +// --------------------------------------------------------------------------- +// Manifest shape +// --------------------------------------------------------------------------- + +export interface LmmManifest { + /** Chain id the demo was deployed on (1 = anvil mainnet fork). */ + chainId: number; + /** Aragon OSx + Capital Router deployment addresses. */ + aragon: { + daoFactory: Address; + pluginSetupProcessor: Address; + }; + /** The Lido Money Machine DAO + its plugins. */ + lmm: { + dao: Address; + /** Top-level dispatcher plugin (multi-dispatch policy). */ + dispatcher: Address; + /** Embedded strategies, in dispatch order. */ + strategies: Array<{ + address: Address; + kind: 'WRAP' | 'UNIV2_LIQUIDITY' | 'GATED_COWSWAP' | string; + label: string; + }>; + /** Optional: budget/gate/epoch addresses for each strategy. */ + primitives?: { + budgets?: Array<{ address: Address; kind: string }>; + gates?: Array<{ address: Address; kind: string }>; + epochProviders?: Array<{ address: Address }>; + }; + }; + /** Hardcoded DAO metadata served by the manifest (no IPFS for the demo). */ + metadata: { + name: string; + description: string; + avatarUrl?: string; + links?: Array<{ label: string; url: string }>; + }; + /** + * Fingerprint of the deployment. We compare this against the indexer's + * Dao.address on load — mismatches signal a stale manifest or wrong + * indexer endpoint. See safety.ts → manifestFingerprintCheck(). + */ + fingerprint?: string; +} + +// --------------------------------------------------------------------------- +// React hook: load + cache the manifest +// --------------------------------------------------------------------------- + +interface ManifestState { + manifest: LmmManifest | undefined; + error: Error | undefined; + loading: boolean; +} + +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(); +}; diff --git a/src/modules/flow/demo/safety.test.ts b/src/modules/flow/demo/safety.test.ts new file mode 100644 index 0000000000..e6230d27a6 --- /dev/null +++ b/src/modules/flow/demo/safety.test.ts @@ -0,0 +1,64 @@ +// 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, + aragon: { + daoFactory: '0x1111111111111111111111111111111111111111', + pluginSetupProcessor: '0x2222222222222222222222222222222222222222', + }, + lmm: { + dao: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + dispatcher: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + strategies: [], + }, + metadata: { name: 'LMM demo', description: '' }, +}; + +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..79eda46f91 --- /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-team.xyz" would match "lmm-demo.aragon-team.xyz"). + // 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/pages/flowOverviewPage/flowOverviewPageClient.tsx b/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx index 461fdbd745..563f5564ba 100644 --- a/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx +++ b/src/modules/flow/pages/flowOverviewPage/flowOverviewPageClient.tsx @@ -9,6 +9,7 @@ import { FlowLoadError, FlowOverviewPageSkeleton, } from '../../components/flowSkeletons'; +import { LmmDemoBanner } from '../../demo/LmmDemoBanner'; import { useFlowData } from '../../hooks'; export interface IFlowOverviewPageClientProps { @@ -31,6 +32,7 @@ export const FlowOverviewPageClient: React.FC = ( return (
    + diff --git a/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx index 61b0a49ca5..7129e7c3cd 100644 --- a/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx +++ b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx @@ -18,7 +18,13 @@ 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 type { IConfirmDispatchDialogParams } from '../../dialogs/confirmDispatchDialog'; import { useFlowDataContext } from '../../providers/flowDataProvider'; import type { IFlowPolicy } from '../../types'; @@ -104,6 +110,7 @@ export const FlowPolicyDetailPageClient: React.FC< return (
    +
    @@ -138,6 +145,7 @@ export const FlowPolicyDetailPageClient: React.FC< > Edit ↗ + {LMM_DEMO_MODE && }
    diff --git a/src/modules/flow/types.ts b/src/modules/flow/types.ts index bf67134e12..755a76accc 100644 --- a/src/modules/flow/types.ts +++ b/src/modules/flow/types.ts @@ -89,9 +89,13 @@ export interface IFlowDispatch { 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'; + status?: 'ok' | 'failed' | 'skipped'; failureReason?: string; + /** Decoded revert reason when `status === 'skipped'`. */ + skippedReason?: string; } export type FlowEventKind = @@ -281,7 +285,60 @@ export interface IFlowOrchestratorLeg { amountIn?: number; tokenIn?: FlowTokenSymbol; recipientsCount: number; - status: 'ok' | 'failed'; + 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; } /** @@ -310,6 +367,13 @@ export interface IFlowOrchestrator { * `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[]; lastRunAt?: string; totalRuns: number; diff --git a/src/modules/flow/utils/envioFlowMapper.ts b/src/modules/flow/utils/envioFlowMapper.ts index b684865dbf..b63cc30db4 100644 --- a/src/modules/flow/utils/envioFlowMapper.ts +++ b/src/modules/flow/utils/envioFlowMapper.ts @@ -25,15 +25,18 @@ import { PolicyStrategyType, } from '@/shared/api/daoService'; import type { + EnvioEmbeddedStrategyKind, EnvioStrategyType, IEnvioExecutionTransfer, IEnvioPolicy, IEnvioPolicyEvent, IEnvioPolicyExecution, IEnvioRecipientAggregate, + IEnvioStrategy, IFlowDaoDataResponse, } from '@/shared/api/flowIndexer'; import type { + FlowEmbeddedStrategyKind, FlowEventKind, FlowPolicyStatus, FlowPolicyStrategy, @@ -42,6 +45,7 @@ import type { IFlowDao, IFlowDaoData, IFlowDispatch, + IFlowEmbeddedStrategy, IFlowEvent, IFlowGroupedPolicies, IFlowOrchestrator, @@ -134,6 +138,16 @@ const STRATEGY_LABEL: Record = { 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 = { @@ -145,6 +159,12 @@ const STRATEGY_VERB: Record = { multiClaimer: 'claimed', uniswapRouter: 'swapped', cowSwapRouter: 'swapped', + wrapStrategy: 'wrapped', + univ2LiquidityStrategy: 'added as liquidity', + gatedCowSwapStrategy: 'swapped', + transferStrategy: 'transferred', + epochTransferStrategy: 'streamed', + burnStrategy: 'burnt', }; const EVENT_KIND: Record = { @@ -154,6 +174,14 @@ const EVENT_KIND: Record = { 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 = { @@ -163,6 +191,14 @@ const EVENT_TITLE: Record = { 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 => @@ -259,8 +295,20 @@ interface ITokenTotal { * 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 => - t.decodedFrom !== 'unknown' && t.decodedFrom !== 'swapIn'; + !INTERNAL_TRANSFER_KINDS.has(t.decodedFrom); const sumOutboundByToken = ( transfers: readonly IEnvioExecutionTransfer[], @@ -308,6 +356,36 @@ const pickDominantOutboundToken = ( 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 => { @@ -366,12 +444,43 @@ const mapExecutionToDispatch = ( ); 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); + + 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, + }; + } + if (isSwap && swapIn) { return { id: execution.id, @@ -455,15 +564,26 @@ 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; } - if (!policy.lastDispatchAt) { - return null; - } const epochSeconds = restPolicy.strategy.source.epochInterval; if (!epochSeconds) { return null; @@ -475,6 +595,43 @@ const deriveCooldown = ( }; }; +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 || 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) // --------------------------------------------------------------------------- @@ -820,6 +977,87 @@ const mapPolicy = (params: { }; }; +// --------------------------------------------------------------------------- +// 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; +}; + // --------------------------------------------------------------------------- // Orchestrator synthesis (Multi-dispatch) // --------------------------------------------------------------------------- @@ -935,6 +1173,7 @@ const buildOrchestrator = (params: { ? isoFromSeconds(uninstallEvent.blockTimestamp) : undefined, chain, + embeddedStrategies: mapEmbeddedStrategies(envioPolicy.strategies), runs, lastRunAt: runs[0]?.at, totalRuns: runs.length, 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/flowIndexerTypes.ts b/src/shared/api/flowIndexer/flowIndexerTypes.ts index b7f0041c65..8f435805c7 100644 --- a/src/shared/api/flowIndexer/flowIndexerTypes.ts +++ b/src/shared/api/flowIndexer/flowIndexerTypes.ts @@ -18,6 +18,10 @@ export type EnvioTransferDecodedFrom = | 'transferFrom' | 'native' | 'swapIn' + | 'wrap' + | 'univ2AddLiquidity' + | 'swapPresign' + | 'approve' | 'unknown'; export interface IEnvioExecutionTransfer { @@ -29,7 +33,7 @@ export interface IEnvioExecutionTransfer { token: IEnvioToken; } -export type EnvioExecutionKind = 'DISPATCH' | 'CLAIM'; +export type EnvioExecutionKind = 'DISPATCH' | 'CLAIM' | 'DISPATCH_SKIPPED'; export interface IEnvioPolicyExecution { id: string; @@ -41,6 +45,92 @@ export interface IEnvioPolicyExecution { 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 = @@ -49,7 +139,15 @@ export type EnvioPolicyEventKind = | 'UNINSTALLED' | 'SETTINGS_UPDATED' | 'FAILSAFE_UPDATED' - | 'STRATEGY_FAILED'; + | '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; @@ -73,12 +171,22 @@ export type EnvioStrategyType = | 'multiRouter' | 'multiClaimer' | 'uniswapRouter' - | 'cowSwapRouter'; + | '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 { @@ -97,6 +205,9 @@ export interface IEnvioPolicy { 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 { diff --git a/src/shared/api/flowIndexer/index.ts b/src/shared/api/flowIndexer/index.ts index aabd2762f0..7696ae41db 100644 --- a/src/shared/api/flowIndexer/index.ts +++ b/src/shared/api/flowIndexer/index.ts @@ -6,17 +6,26 @@ export { } from './flowIndexerClient'; export { FlowIndexerKey, flowIndexerKeys } from './flowIndexerKeys'; export type { + EnvioBudgetKind, + EnvioEmbeddedStrategyKind, EnvioExecutionKind, + EnvioGateKind, EnvioPolicyEventKind, EnvioPolicyStatus, EnvioStrategyType, EnvioTransferDecodedFrom, + IEnvioBudget, IEnvioDao, + IEnvioEpochProvider, IEnvioExecutionTransfer, + IEnvioGate, IEnvioPolicy, IEnvioPolicyEvent, IEnvioPolicyExecution, IEnvioRecipientAggregate, + IEnvioStrategy, + IEnvioStrategyRef, + IEnvioSwapOrder, IEnvioToken, IFlowDaoDataResponse, } from './flowIndexerTypes'; diff --git a/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts b/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts index 25a8280354..4edd0e37b0 100644 --- a/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts +++ b/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts @@ -11,7 +11,72 @@ import type { IFlowDaoDataResponse } from '../flowIndexerTypes'; * 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 @@ -29,54 +94,49 @@ const FLOW_DAO_DATA_QUERY = /* GraphQL */ ` id address chainId + name + description + avatarUrl } lastExecution { - id - kind - blockNumber - blockTimestamp - txHash - logIndex - transferCount - decodedTransferCount - transfers(order_by: { actionIndex: asc }) { - id - amount - to - decodedFrom - actionIndex - token { - id - address - symbol - decimals - } - } + ...ExecutionFields } executions( order_by: { blockTimestamp: desc } limit: $executionLimit ) { + ...ExecutionFields + } + strategies(order_by: { index: asc }) { id + address kind - blockNumber - blockTimestamp - txHash - logIndex - transferCount - decodedTransferCount - transfers(order_by: { actionIndex: asc }) { + index + paused + configJson + budget { + id + address + kind + floorEpochs + targetEpoch + epochProviderAddress + initializedAt + } + gate { + id + address + kind + threshold + maxStaleness + oracle + baseToken + quoteToken + } + epochProvider { id - amount - to - decodedFrom - actionIndex - token { - id - address - symbol - decimals - } + address + epochLength } } events(order_by: { blockTimestamp: desc }, limit: 25) { 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..84e4a2fe9c --- /dev/null +++ b/src/shared/lidoPreview/render/reactFlow.ts @@ -0,0 +1,394 @@ +// 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); + } + + return { nodes, edges }; +} + +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..d2e158e306 --- /dev/null +++ b/src/shared/lidoPreview/simulate/predictors.ts @@ -0,0 +1,627 @@ +// 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)', +]); +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; + } + + return { + status: 'executed', + budget: { + amount: budgetA.amount, + token: budgetA.token, + provenance: budgetA.provenance, + }, + transfers: [], + 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 to lpRecipient (NOT the DAO) — no `produces` on the vault. + produces: [], + }, + ], + notes: [ + `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: [], + produces: [], + }, + ], + 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", From efcac478263afb2a8ace543d9b826df8d95af4cf Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 26 May 2026 22:15:31 +0200 Subject: [PATCH 04/13] feat: Update Lido Money Machine demo configuration and routing - Added symlink for demo manifest in public directory to facilitate local development. - Updated environment variables for demo mode in `.env.local` to enable demo features. - Enhanced documentation for production readiness and VM deployment, clarifying the new nginx routing setup. - Refactored dispatch dialog to conditionally load demo components based on the demo mode. - Removed Caddy configuration in favor of host nginx for improved routing and security. This commit streamlines the demo setup and improves clarity for developers working with the Lido Money Machine. --- .gitignore | 3 + config/.env.local | 2 +- docs/lido-mmd-production-readiness.md | 5 +- docs/lido-mmd-status.md | 9 +- infra/lmm-demo/README.md | 190 ++++++--- infra/lmm-demo/vercel.env.example | 18 +- infra/lmm-demo/vm/.env.vm.example | 22 +- infra/lmm-demo/vm/Caddyfile | 71 ---- infra/lmm-demo/vm/docker-compose.yml | 66 ++- infra/lmm-demo/vm/init-demo.sh | 55 ++- infra/lmm-demo/vm/nginx.lmm-demo.conf | 118 ++++++ infra/lmm-demo/vm/vm-README.md | 232 +++++++++-- .../dispatchTransactionDialog.tsx | 38 +- .../dispatchDialog/lmmDemoDispatchDialog.tsx | 17 +- .../flow/components/flowKpiRow/flowKpiRow.tsx | 22 +- .../flow/components/flowLede/flowLede.tsx | 13 +- .../flowMultiDispatchCard.tsx | 64 ++- .../flowPoliciesSection.tsx | 23 +- .../flowPolicyCard/flowPolicyCard.tsx | 2 +- .../flowPolicyChart/flowPolicyChart.tsx | 17 +- .../flowPolicyStructure.tsx | 27 +- .../flowPolicyStructure/flowPolicyTree.tsx | 15 +- .../flowPrimitives/flowStrategyChip.tsx | 7 + .../flowSparkline/flowSparkline.tsx | 16 +- .../lidoMoneyMachine/LmmCheatsMenu.tsx | 9 +- .../lidoMoneyMachine/LmmPolicyTopology.tsx | 52 ++- .../lidoMoneyMachine/LmmSimulationCards.tsx | 385 ++++++++++++++++++ .../lidoMoneyMachine/TopologyView.tsx | 27 +- .../components/lidoMoneyMachine/actions.ts | 11 +- src/modules/flow/demo/LmmDemoBanner.tsx | 80 +++- src/modules/flow/demo/lmmDaoOverride.ts | 85 +++- src/modules/flow/demo/lmmDemoConfig.ts | 299 +++++++------- src/modules/flow/demo/safety.test.ts | 13 +- src/modules/flow/demo/safety.ts | 4 +- src/modules/flow/demo/useLmmManifest.ts | 165 ++++++++ .../flowPolicyDetailPageClient.tsx | 94 ++++- .../flow/providers/flowDataProvider.tsx | 27 ++ .../flow/providers/useEnvioFlowData.ts | 15 +- src/modules/flow/types.ts | 9 + src/modules/flow/utils/envioFlowMapper.ts | 59 ++- src/shared/lidoPreview/render/reactFlow.ts | 17 +- 41 files changed, 1853 insertions(+), 550 deletions(-) delete mode 100644 infra/lmm-demo/vm/Caddyfile create mode 100644 infra/lmm-demo/vm/nginx.lmm-demo.conf create mode 100644 src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx create mode 100644 src/modules/flow/demo/useLmmManifest.ts 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/config/.env.local b/config/.env.local index f15225ffa6..6a60151361 100644 --- a/config/.env.local +++ b/config/.env.local @@ -25,7 +25,7 @@ NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=http://localhost:8080/v1/graphql # 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=0 +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 diff --git a/docs/lido-mmd-production-readiness.md b/docs/lido-mmd-production-readiness.md index 06955dc025..7d0441addb 100644 --- a/docs/lido-mmd-production-readiness.md +++ b/docs/lido-mmd-production-readiness.md @@ -1,6 +1,7 @@ # Lido Money Machine — production readiness checklist -The MVP shipped on `money-machine-dashboard` (and any branch that includes +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 @@ -17,7 +18,7 @@ rg LMM_DEMO_HACK app/src capital-flow-indexer/src | Area | MVP | Production | | ----------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| **Data source** | Local Envio at `localhost:8080` / VM Hasura behind Caddy | Aragon-hosted Envio (single endpoint for all flow data). | +| **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. | diff --git a/docs/lido-mmd-status.md b/docs/lido-mmd-status.md index 6038358dfd..b7899c0a7b 100644 --- a/docs/lido-mmd-status.md +++ b/docs/lido-mmd-status.md @@ -1,6 +1,7 @@ # Lido MMD demo — live status -> Living doc for the `money-machine-dashboard` branch. Update the table +> 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. @@ -9,7 +10,7 @@ | Repo | Branch | Purpose | | -------------------------- | ---------------------------- | --------------------------------------------- | -| `aragon/app` | `money-machine-dashboard` | UI + demo override layer | +| `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) | @@ -17,7 +18,7 @@ | Track / task | Owner agent | State | Notes | | ---------------------------------------------------------------------------------------------------- | ----------- | ------------- | ----- | -| VM stack: docker-compose (anvil persistent, envio + postgres + hasura, caddy), Caddyfile, init-demo.sh, vm-README.md | demo-infra | **done** | `infra/lmm-demo/vm/`. Persistent anvil via `--state`, caddy serves manifest from shared volume. | +| 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.*. | @@ -44,7 +45,7 @@ 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-machine-dashboard`. +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. diff --git a/infra/lmm-demo/README.md b/infra/lmm-demo/README.md index 5af03d93b3..1d2e24e330 100644 --- a/infra/lmm-demo/README.md +++ b/infra/lmm-demo/README.md @@ -11,42 +11,65 @@ 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 + Caddy and the app - reads from `https://` via the Vercel preview URL. See - [`vm/vm-README.md`](./vm/vm-README.md). +* **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 -just anvil # terminal 1: anvil mainnet fork on :8545 -just demo-up # terminal 2: deploys CR slice + LMM DAO - -# In capital-flow-indexer (this repo's sibling) -cp .env.example .env # set ENVIO_RPC_URL=http://localhost:8545 -pnpm envio dev # terminal 3: indexer + hasura on :8080 - -# In app/ -cp .env.example .env.local -echo 'NEXT_PUBLIC_LMM_DEMO_MODE=1' >> .env.local -echo 'NEXT_PUBLIC_LMM_MANIFEST_URL=/lmm-manifest.json' >> .env.local -echo 'NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=http://localhost:8080/v1/graphql' >> .env.local -echo 'NEXT_PUBLIC_LMM_RPC_URL=http://localhost:8545' >> .env.local - -# Symlink the just-generated manifest into /public so the app can fetch it. -ln -sf ../../dao-launchpad/lido/preview/script/demo/manifest.json \ +# 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 - -pnpm dev # terminal 4: open /dao/ethereum-mainnet//flow +# 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 `manifest.lmm.dao` from the generated +The DAO address to navigate to is `lmm.dao` from the generated `manifest.json`. --- @@ -55,43 +78,89 @@ The DAO address to navigate to is `manifest.lmm.dao` from the generated ### Prerequisites -* `pnpm` 9+, Node.js 20+ -* `foundry` (anvil, cast, forge) — `curl -L https://foundry.paradigm.xyz | bash` -* `just` -* A mainnet archive RPC URL for the Anvil fork (Alchemy / Infura). - Export it as `MAINNET_RPC=https://...`. -* The three sibling repos checked out alongside `app/`: +* `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/preview -just demo-up # boots anvil, deploys CR + LMM DAO +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 ``` -`just demo-up` is idempotent — it tears down any previous fork before -deploying. The final step writes `script/demo/manifest.json` with all -the addresses the UI needs. +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 -cp .env.example .env -# Edit .env: -# ENVIO_RPC_URL=http://localhost:8545 -# ENVIO_CHAIN_ID=1 -# ENVIO_START_BLOCK=0 -pnpm install -pnpm envio dev # serves GraphQL on http://localhost:8080 +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.InstallationPrepared` — no need to plug +and `PluginSetupProcessor.InstallationApplied` — no need to plug addresses in by hand. ### 3. Run the app @@ -99,22 +168,33 @@ addresses in by hand. ```bash cd ../app pnpm install -cp .env.example .env.local ``` -Append the LMM demo flags to `.env.local`: +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: +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/preview/script/demo/manifest.json \ +ln -sf ../../dao-launchpad/lido/script/demo/manifest.json \ public/lmm-manifest.json ``` @@ -140,8 +220,11 @@ topology, the cheats menu, and the demo-mode banner. * 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`, etc. — - defined in `dao-launchpad/lido/preview/justfile`. +* **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/`. --- @@ -209,7 +292,14 @@ When Jordi ships an update: * **"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` (or unset) in `.env.local`. + 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 diff --git a/infra/lmm-demo/vercel.env.example b/infra/lmm-demo/vercel.env.example index 670fe93b1c..94f78361fc 100644 --- a/infra/lmm-demo/vercel.env.example +++ b/infra/lmm-demo/vercel.env.example @@ -10,14 +10,14 @@ # production code paths take over. NEXT_PUBLIC_LMM_DEMO_MODE=1 -# Manifest URL served by the demo VM's caddy. Replace `` with the -# actual host (e.g. lmm-demo.aragon-team.xyz). -NEXT_PUBLIC_LMM_MANIFEST_URL=https:///manifest.json +# 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 caddy. -NEXT_PUBLIC_FLOW_INDEXER_ENDPOINT=https:///graphql +# 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 caddy. Adding the hostname to LMM_RPC_ALLOWLIST in -# `src/modules/flow/demo/lmmDemoConfig.ts` is required — `assertForkRpc` -# blocks writes against any host outside the allowlist. -NEXT_PUBLIC_LMM_RPC_URL=https:///rpc +# 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 index 238e9aba68..44ed9916b6 100644 --- a/infra/lmm-demo/vm/.env.vm.example +++ b/infra/lmm-demo/vm/.env.vm.example @@ -1,8 +1,10 @@ # Copy to .env, fill in values, then `docker compose up -d`. - -# Public DNS that points at this VM. Caddy will provision a Let's Encrypt -# cert for this hostname automatically. -DOMAIN=lmm-demo.aragon-team.xyz +# +# 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. @@ -17,9 +19,19 @@ FORK_BLOCK= POSTGRES_PASSWORD=changeme # Hasura admin secret. Anonymous queries are allowed via the `public` role -# we configure inside Hasura — this only protects the admin console. +# 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/) diff --git a/infra/lmm-demo/vm/Caddyfile b/infra/lmm-demo/vm/Caddyfile deleted file mode 100644 index 1f4c025926..0000000000 --- a/infra/lmm-demo/vm/Caddyfile +++ /dev/null @@ -1,71 +0,0 @@ -# Lido Money Machine demo — Caddy v2 config -# -# Exposes three public endpoints over TLS with CORS:* so the Vercel-hosted -# Aragon app can talk to them from any preview origin: -# -# https://$DOMAIN/rpc → anvil:8545 (JSON-RPC for reads + anvil-write actions) -# https://$DOMAIN/graphql → hasura:8080 (Envio's Hasura GraphQL endpoint, public role) -# https://$DOMAIN/manifest.json → file_server /srv/lmm/manifest.json (deployment addresses) -# -# CORS is wide-open intentionally — this is throw-away demo infra, not prod. -# -# Caddy auto-provisions a Let's Encrypt cert for $DOMAIN. - -{$DOMAIN} { - encode zstd gzip - - # Shared CORS handler — applied to every route below. - @cors_preflight method OPTIONS - handle @cors_preflight { - header { - Access-Control-Allow-Origin "*" - Access-Control-Allow-Methods "GET, POST, OPTIONS" - Access-Control-Allow-Headers "Content-Type, Authorization, X-Demo-Key" - Access-Control-Max-Age "86400" - } - respond "" 204 - } - - header { - Access-Control-Allow-Origin "*" - Access-Control-Allow-Methods "GET, POST, OPTIONS" - Access-Control-Allow-Headers "Content-Type, Authorization, X-Demo-Key" - # Stop browsers from caching the manifest across redeploys. - Cache-Control "no-store" - } - - # JSON-RPC: forwarded to anvil. POST-only in practice; OPTIONS handled above. - handle /rpc* { - reverse_proxy anvil:8545 - } - - # GraphQL: forwarded to Hasura. Public role is enabled in compose, - # so anonymous queries work; mutations require admin secret which - # we don't expose externally. - handle /graphql* { - uri strip_prefix /graphql - rewrite * /v1/graphql{uri} - reverse_proxy hasura:8080 - } - - # Manifest: served straight off the anvil volume (written by init-demo.sh). - handle /manifest.json { - root * /srv/lmm - file_server - } - - # Healthcheck. - handle /healthz { - respond "ok" 200 - } - - # Everything else: 404 (we don't expose anvil/hasura/postgres consoles publicly). - handle { - respond "Not Found" 404 - } - - log { - output stdout - format console - } -} diff --git a/infra/lmm-demo/vm/docker-compose.yml b/infra/lmm-demo/vm/docker-compose.yml index 7abbf5bf0d..f8db1f95a6 100644 --- a/infra/lmm-demo/vm/docker-compose.yml +++ b/infra/lmm-demo/vm/docker-compose.yml @@ -1,23 +1,24 @@ # Lido Money Machine demo — VM stack # -# Five services: anvil (mainnet fork), postgres + envio + hasura (indexer), -# caddy (TLS + CORS proxy). All ports are internal except caddy:443. -# -# Designed for a single VM (Hetzner CX22, DO 2vCPU/4GB or similar). +# 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 -# # edit .env: MAINNET_RPC, DOMAIN, optionally FORK_BLOCK +# $EDITOR .env # MAINNET_RPC + POSTGRES_PASSWORD + HASURA_ADMIN_SECRET # docker compose up -d -# ./init-demo.sh # one-shot: clones dao-launchpad@f/lido-demo, runs just demo-up +# ./init-demo.sh # # Tear down: # docker compose down -v # WARNING: drops anvil state + indexer DB # # Daily ops: -# docker compose logs -f anvil # follow fork +# docker compose logs -f anvil # docker compose exec anvil cast block latest --rpc-url http://localhost:8545 -# curl https://$DOMAIN/manifest.json | jq . +# curl https://tests.aragon.in/manifest.json | jq . name: lmm-demo @@ -40,8 +41,10 @@ services: - "--silent" volumes: - lmm-data:/data - expose: - - "8545" + # 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 @@ -79,8 +82,21 @@ services: ENVIO_PG_USER: postgres ENVIO_PG_PASSWORD: ${POSTGRES_PASSWORD:-changeme} ENVIO_PG_DATABASE: envio - # Anvil RPC, used by handlers for token metadata eth_calls + # 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 + # Block at which Envio starts indexing chain-id 1. Init-demo.sh writes + # the actual fork-block (manifest.blockNumber - 5) into /srv/lmm/.env + # after every `just demo-up`; we read it from there via env_file. The + # default here is the bootstrap value used before init-demo.sh has run + # for the first time. + ENVIO_DEMO_START_BLOCK: ${ENVIO_DEMO_START_BLOCK:-25179300} + 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: /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"] @@ -97,34 +113,14 @@ services: HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:-changeme}@postgres:5432/envio HASURA_GRAPHQL_ENABLE_CONSOLE: "true" HASURA_GRAPHQL_DEV_MODE: "false" - # The console is open without auth on the VM — caddy will proxy /graphql - # publicly (we want unauthenticated reads), but the admin UI should be - # protected. Set HASURA_GRAPHQL_ADMIN_SECRET via .env for that. + # 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" - expose: - - "8080" - - caddy: - image: caddy:2-alpine - restart: unless-stopped - depends_on: - - anvil - - hasura + # Loopback-only — host nginx proxies /graphql to here. ports: - - "80:80" - - "443:443" - environment: - DOMAIN: ${DOMAIN} - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy-data:/data - - caddy-config:/config - # Manifest is written by init-demo.sh into the anvil volume; caddy serves it static. - - lmm-data:/srv/lmm:ro + - "127.0.0.1:8080:8080" volumes: lmm-data: lmm-pg: - caddy-data: - caddy-config: diff --git a/infra/lmm-demo/vm/init-demo.sh b/infra/lmm-demo/vm/init-demo.sh index 06e4b5b5bc..3b91406a44 100755 --- a/infra/lmm-demo/vm/init-demo.sh +++ b/infra/lmm-demo/vm/init-demo.sh @@ -3,25 +3,30 @@ # 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 + caddy. +# 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 (PrepareDemo writes a marker), skip. +# 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. Copy the resulting script/demo/manifest.json into the anvil volume -# so caddy can serve it as https://$DOMAIN/manifest.json. +# 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: -# DOMAIN=lmm-demo.aragon-team.xyz MAINNET_RPC=... ./init-demo.sh +# 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://localhost:8545}" +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' "$*"; } @@ -90,13 +95,37 @@ else just demo-up fi -log "copying manifest into the lmm-data docker volume (anvil:/data, caddy:/srv/lmm)" -ANVIL_CID=$(docker compose -f "$COMPOSE_FILE" ps -q anvil) -if [ -z "$ANVIL_CID" ]; then - err "anvil container not running (compose file: $COMPOSE_FILE)" - exit 1 +log "writing manifest into ${MANIFEST_OUTPUT_DIR}/manifest.json (host bind path served by nginx)" +mkdir -p "$MANIFEST_OUTPUT_DIR" +# Atomic replace — avoids serving a half-written file if nginx reads while +# init-demo.sh runs. +install -m 0644 script/demo/manifest.json "$MANIFEST_OUTPUT_DIR/manifest.json.tmp" +mv -f "$MANIFEST_OUTPUT_DIR/manifest.json.tmp" "$MANIFEST_OUTPUT_DIR/manifest.json" + +# Pin Envio's mainnet start_block to the actual fork block (manifest's +# blockNumber minus a small buffer for off-by-one safety). docker-compose +# reads this via env_file: /srv/lmm/.env.indexer. Without this, a fresh +# `just demo-up` advances the fork block but the indexer keeps using the +# old start_block baked into config.yaml's default — which on first boot +# is fine, but on subsequent re-deploys means re-scanning known blocks. +FORK_BLOCK=$(jq -r '.blockNumber' script/demo/manifest.json) +INDEXER_START_BLOCK=$((FORK_BLOCK - 5)) +log "pinning Envio start_block to ${INDEXER_START_BLOCK} (manifest fork block ${FORK_BLOCK} − 5)" +cat >"${MANIFEST_OUTPUT_DIR}/.env.indexer.tmp" </dev/null | grep -q '^envio$'; then + log "restarting envio container to apply new start_block" + docker compose -f "$COMPOSE_FILE" restart envio fi -docker cp script/demo/manifest.json "$ANVIL_CID:/data/manifest.json" log "done. LMM DAO: $(jq -r .lmm.dao script/demo/manifest.json)" -log "manifest served at: https://${DOMAIN:-localhost}/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..2ab60879b2 --- /dev/null +++ b/infra/lmm-demo/vm/nginx.lmm-demo.conf @@ -0,0 +1,118 @@ +# 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; + + # `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"; + + 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 index 7786130b59..682f591f59 100644 --- a/infra/lmm-demo/vm/vm-README.md +++ b/infra/lmm-demo/vm/vm-README.md @@ -1,29 +1,116 @@ # Lido Money Machine demo — VM deployment Self-contained docker-compose stack for hosting the LMM demo behind a public -TLS endpoint. Single VM, five containers, ~3 GB RAM at idle. +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). --- -## What's inside +## When to use the VM (vs local-first) -| Service | Image | Purpose | -|----------|--------------------------------|------------------------------------------------------| -| anvil | `ghcr.io/foundry-rs/foundry` | Mainnet fork, persistent state (`/data/anvil.json`). | -| postgres | `postgres:17-alpine` | Envio's index DB. | -| envio | `node:22-bookworm-slim` | Capital-flow-indexer (mounted from host). | -| hasura | `hasura/graphql-engine:v2.42` | GraphQL frontend for Envio's Postgres. | -| caddy | `caddy:2-alpine` | TLS + CORS proxy. Public-facing. | +| 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://$DOMAIN/rpc` — JSON-RPC against anvil -- `https://$DOMAIN/graphql` — Envio GraphQL (public role, read-only) -- `https://$DOMAIN/manifest.json` — deployment manifest -- `https://$DOMAIN/healthz` — liveness +- `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. --- @@ -43,7 +130,11 @@ sudo apt install -y just || \ (curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | sudo bash -s -- --to /usr/local/bin) ``` -Open ports 80 + 443 in the firewall (`ufw allow 80,443/tcp`). +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). --- @@ -53,36 +144,55 @@ Open ports 80 + 443 in the firewall (`ufw allow 80,443/tcp`). # 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 -# Or: scp -r ~/dev/aragon/capital-flow-indexer root@vm:/srv/ -# 2. Pull this directory onto the VM (or scp it from your laptop). +# 2. Pull the app repo onto the VM. cd /srv -git clone -b money-mchine-dashboard https://github.com/aragon/app.git +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. +# 3. Configure the compose stack. cp .env.vm.example .env -$EDITOR .env # set DOMAIN + MAINNET_RPC + POSTGRES_PASSWORD + HASURA_ADMIN_SECRET +$EDITOR .env # set MAINNET_RPC + POSTGRES_PASSWORD + HASURA_ADMIN_SECRET -# 4. Start the stack. +# 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 (one-shot; idempotent). -DOMAIN=$(grep '^DOMAIN=' .env | cut -d= -f2) \ - ./init-demo.sh - -# 6. Verify. -curl -s https://$DOMAIN/healthz # → ok -curl -s https://$DOMAIN/manifest.json | jq .lmm.dao # → 0x... +# 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://$DOMAIN/rpc | jq .result # → 0x... + https://tests.aragon.in/rpc | jq .result # → 0x... curl -s -X POST -H 'Content-Type: application/json' \ -d '{"query":"query { Dao { id address } }"}' \ - https://$DOMAIN/graphql | jq . # → list with LMM DAO + 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 @@ -92,7 +202,7 @@ curl -s -X POST -H 'Content-Type: application/json' \ ```bash docker compose down -v # drops anvil state + indexer DB docker compose up -d -./init-demo.sh # re-deploys +./init-demo.sh # re-deploys, rewrites manifest.json atomically ``` ### Anvil only (keep indexer DB) @@ -100,7 +210,7 @@ docker compose up -d ```bash docker compose restart anvil ./init-demo.sh # re-deploys against fresh fork -# After that you may want to re-index too: +# After that you usually want to re-index too: docker compose restart envio ``` @@ -115,6 +225,7 @@ docker compose exec anvil sh -c 'cast tx 0x... --rpc-url http://localhost:8545' ```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) ``` --- @@ -135,6 +246,47 @@ 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**. @@ -144,23 +296,17 @@ This is **throw-away demo infrastructure**. - 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. - -If you need to lock this down (private demo for one client, say): -- Set `HASURA_GRAPHQL_UNAUTHORIZED_ROLE` to `null` and require API key headers. -- Replace `Access-Control-Allow-Origin: *` in the Caddyfile with the specific - preview domain. -- Use BasicAuth in Caddy via the `basicauth` directive on /rpc. + 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 | +| 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 diff --git a/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx b/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx index edefeb3864..d884ac0358 100644 --- a/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx +++ b/src/modules/capitalFlow/dialogs/dispatchDialog/dispatchTransactionDialog.tsx @@ -1,11 +1,13 @@ import { invariant } from '@aragon/gov-ui-kit'; +import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; 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 { useIsLmmDemoDao } from '@/modules/flow/demo/lmmDemoConfig'; +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 { @@ -21,7 +23,20 @@ import { useTranslations } from '@/shared/components/translationsProvider'; import { useStepper } from '@/shared/hooks/useStepper'; import { CapitalFlowDialogId } from '../../constants/capitalFlowDialogId'; import type { IRouterSelectorDialogParams } from '../routerSelectorDialog'; -import { LmmDemoDispatchDialog } from './lmmDemoDispatchDialog'; + +// 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; @@ -73,12 +88,21 @@ export const DispatchTransactionDialog: React.FC< // 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 — in that case we proceed with the production - // wagmi flow rather than block the dialog. All hooks below this branch - // are inside `ProductionDispatchDialog` to keep the hook order stable. + // 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 (isLmmDemo === true) { - return ; + 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 ; }; diff --git a/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx b/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx index be016057a4..a28ae3269d 100644 --- a/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx +++ b/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx @@ -20,13 +20,10 @@ import { deriveAddressesFromManifest, dispatchAction, } from '@/modules/flow/components/lidoMoneyMachine/actions'; -import { StepsView } from '@/modules/flow/components/lidoMoneyMachine/StepsView'; -import { - LMM_DEMO_MODE, - LMM_RPC_URL, - useLmmManifest, -} from '@/modules/flow/demo/lmmDemoConfig'; +import { LmmSimulationCards } from '@/modules/flow/components/lidoMoneyMachine/LmmSimulationCards'; +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 { useDialogContext } from '@/shared/components/dialogProvider'; import { type FlowGraph, @@ -91,7 +88,7 @@ export const LmmDemoDispatchDialog: React.FC< const run = async () => { try { const publicClient = getPublicClient(); - const dispatcher = manifest.lmm.dispatcher as Address; + const dispatcher = manifest.lmm.dispatcherPlugin as Address; const dao = manifest.lmm.dao as Address; const addresses = deriveAddressesFromManifest( manifest as unknown as Parameters< @@ -209,11 +206,7 @@ export const LmmDemoDispatchDialog: React.FC<
    )} - {flow && ( -
    - -
    - )} + {flow && } {txError && (
    Dispatch failed: {txError} diff --git a/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx b/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx index 91e066f831..de4745173e 100644 --- a/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx +++ b/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx @@ -1,9 +1,5 @@ import classNames from 'classnames'; -import { - FLOW_ACTIVE_STATUSES, - type FlowTokenSymbol, - type IFlowDaoData, -} from '../../types'; +import type { FlowTokenSymbol, IFlowDaoData } from '../../types'; import { formatFlowAmount } from '../../utils/flowFormatters'; export interface IFlowKpiRowProps { @@ -36,15 +32,10 @@ const deltaClasses: Record<'up' | 'down' | 'flat', string> = { export const FlowKpiRow: React.FC = (props) => { const { data } = props; + // Per-dispatch / per-recipient totals stay scoped to leaf policies because + // every orchestrator run is already accounted for via its child legs. const { policies } = data; - const total = policies.length; - const active = policies.filter((p) => - FLOW_ACTIVE_STATUSES.includes(p.status), - ).length; - const paused = policies.filter((p) => p.status === 'paused').length; - const awaiting = policies.filter((p) => p.status === 'awaiting').length; - const readyPolicies = policies.filter((p) => p.status === 'ready'); const readyCount = readyPolicies.length; const pendingByToken = readyPolicies.reduce< @@ -114,11 +105,6 @@ export const FlowKpiRow: React.FC = (props) => { ); const items: IKpiItem[] = [ - { - label: 'Active automations', - value: `${active} / ${total}`, - hint: `${paused} paused · ${awaiting} awaiting`, - }, { label: 'Ready to dispatch', value: `${readyCount}`, @@ -147,7 +133,7 @@ export const FlowKpiRow: React.FC = (props) => { ]; return ( -
    +
    {items.map((item) => (
    = (props) => { const { dao, policies } = data; const readyPolicies = policies.filter((p) => p.status === 'ready'); - const activePolicies = policies.filter((p) => - FLOW_ACTIVE_STATUSES.includes(p.status), - ); const pendingByToken = readyPolicies.reduce< Partial> @@ -64,10 +57,6 @@ export const FlowLede: React.FC = (props) => { narrativeParts.push( `${pendingSummary || `${readyPolicies.length}`} ready to dispatch across ${readyPolicies.length} polic${readyPolicies.length === 1 ? 'y' : 'ies'}`, ); - } else { - narrativeParts.push( - `${activePolicies.length} active automation${activePolicies.length === 1 ? '' : 's'}`, - ); } if (nextReady != null) { narrativeParts.push( diff --git a/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx b/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx index fb3e5ec1ff..aaceed42bf 100644 --- a/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx +++ b/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx @@ -51,6 +51,12 @@ export const FlowMultiDispatchCard: React.FC = ( ) => { 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); @@ -73,10 +79,6 @@ export const FlowMultiDispatchCard: React.FC = ( 0, orchestrator.runs.length - visibleRuns.length, ); - // Orchestrators don't have a dedicated detail page yet — the policy detail route only - // knows about leaf policies, and the data it would render would be a subset of what's - // already on the card (name, chain diagram, runs). Surface everything inline here - // and skip the navigation to avoid a "Policy not found" dead-end. 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. */} = ( {hasEmbedded ? ( ) : ( @@ -292,6 +305,13 @@ const ChainArrow: React.FC = () => ( 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; } /** @@ -303,11 +323,17 @@ interface IFlowEmbeddedChainDiagramProps { */ const FlowEmbeddedChainDiagram: React.FC = ({ strategies, + orchestratorId, + network, + addressOrEns, }) => (
    {strategies.map((s, index) => ( @@ -329,16 +355,30 @@ const STRATEGY_KIND_TONE: Record = { const EmbeddedStrategyChip: React.FC<{ strategy: IFlowEmbeddedStrategy; showArrow: boolean; -}> = ({ strategy, showArrow }) => { + 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 ( <> -
    @@ -375,7 +415,7 @@ const EmbeddedStrategyChip: React.FC<{ )}
    -
    + {showArrow && } ); diff --git a/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx b/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx index aaccfb2750..3f65e7c833 100644 --- a/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx +++ b/src/modules/flow/components/flowPoliciesSection/flowPoliciesSection.tsx @@ -1,7 +1,7 @@ 'use client'; import classNames from 'classnames'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useAddAutomationAction } from '../../hooks/useAddAutomationAction'; import { useFlowDataContext } from '../../providers/flowDataProvider'; import type { IFlowGroupedPolicies, IFlowPolicy } from '../../types'; @@ -61,18 +61,15 @@ export const FlowPoliciesSection: React.FC = ( [groupedPolicies], ); - const defaultPill = pills[0]?.id ?? 'active'; - const [selected, setSelected] = useState(defaultPill); - - // If the bucket the user is currently on becomes empty (e.g. dispatch moved a policy - // out of "Not yet dispatched"), fall back to the first available pill. - useEffect(() => { - if (!pills.some((p) => p.id === selected)) { - setSelected(pills[0]?.id ?? 'active'); - } - }, [pills, selected]); - - const active = pills.find((p) => p.id === selected) ?? pills[0]; + // `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`. diff --git a/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx b/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx index 57b42c40b2..6418efad58 100644 --- a/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx +++ b/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx @@ -150,7 +150,7 @@ export const FlowPolicyCard: React.FC = (props) => { e.stopPropagation()} rel="noopener" diff --git a/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx b/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx index 02fc014286..06d5ba67ed 100644 --- a/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx +++ b/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx @@ -254,7 +254,16 @@ export const FlowPolicyChart: React.FC = (props) => {
    - + {/* `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. */} + = ({ return (
    - + diff --git a/src/modules/flow/components/flowPolicyStructure/flowPolicyStructure.tsx b/src/modules/flow/components/flowPolicyStructure/flowPolicyStructure.tsx index f4b8a0fb0b..cd6bd21502 100644 --- a/src/modules/flow/components/flowPolicyStructure/flowPolicyStructure.tsx +++ b/src/modules/flow/components/flowPolicyStructure/flowPolicyStructure.tsx @@ -1,4 +1,14 @@ +'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, @@ -9,13 +19,17 @@ 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, className } = props; + const { policy, selectedNodeAddress, className } = props; + const isLmmDispatcher = useIsLmmDispatcherPolicy(policy.address); if ( policy.strategy !== 'Multi-dispatch' || @@ -24,6 +38,17 @@ export const FlowPolicyStructure: React.FC = ( return null; } + if (isLmmDispatcher) { + return ( +
    + +
    + ); + } + return (
    diff --git a/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx b/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx index e734ab7bc2..ba8fa18b84 100644 --- a/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx +++ b/src/modules/flow/components/flowPolicyStructure/flowPolicyTree.tsx @@ -4,7 +4,8 @@ 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, useLmmManifest } from '../../demo/lmmDemoConfig'; +import { LMM_DEMO_MODE } from '../../demo/lmmDemoConfig'; +import { useLmmManifest } from '../../demo/useLmmManifest'; import type { IFlowPolicy, IFlowPolicySubRouter, @@ -14,6 +15,10 @@ 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; } @@ -175,7 +180,7 @@ const nodeTypeLabel: Record = { }; export const FlowPolicyTree: React.FC = (props) => { - const { policy, className } = 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 @@ -187,7 +192,8 @@ export const FlowPolicyTree: React.FC = (props) => { LMM_DEMO_MODE && manifest != null && policy.strategy === 'Multi-dispatch' && - policy.address.toLowerCase() === manifest.lmm.dispatcher.toLowerCase(); + policy.address.toLowerCase() === + manifest.lmm.dispatcherPlugin.toLowerCase(); if (isLmmDispatcher && manifest) { return (
    = (props) => { // parent crown node. undefined } - pluginAddress={manifest.lmm.dispatcher as Address} + pluginAddress={manifest.lmm.dispatcherPlugin as Address} + selectedNodeAddress={selectedNodeAddress} />
    ); diff --git a/src/modules/flow/components/flowPrimitives/flowStrategyChip.tsx b/src/modules/flow/components/flowPrimitives/flowStrategyChip.tsx index c602d0723d..02d7574da2 100644 --- a/src/modules/flow/components/flowPrimitives/flowStrategyChip.tsx +++ b/src/modules/flow/components/flowPrimitives/flowStrategyChip.tsx @@ -8,6 +8,13 @@ export interface IFlowStrategyChipProps { 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 ( = (props) => { style={{ height: `${height}px` }} >
    - + {/* 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. */} + = (props) => {
    - + diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx index 8c4708be6c..644a8473f5 100644 --- a/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx @@ -9,12 +9,9 @@ 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, - useLmmManifest, -} from '@/modules/flow/demo/lmmDemoConfig'; +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, @@ -61,7 +58,7 @@ export const LmmCheatsMenu: React.FC = () => { rpc: LMM_RPC_URL, publicClient: getPublicClient(), dao: manifest.lmm.dao, - dispatcher: manifest.lmm.dispatcher, + dispatcher: manifest.lmm.dispatcherPlugin, addresses, }; }, [manifest]); diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx index 4d3b37d2e9..d6d6a35c37 100644 --- a/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx @@ -5,6 +5,7 @@ 'use client'; +import classNames from 'classnames'; import { useEffect, useState } from 'react'; import { type Address, @@ -15,6 +16,7 @@ import { import { mainnet } from 'viem/chains'; import { LMM_RPC_URL } from '@/modules/flow/demo/lmmDemoConfig'; import { inspect, type TopologyGraph } from '@/shared/lidoPreview'; +import { StatusPanel } from './StatusPanel'; import { TopologyView } from './TopologyView'; import { useStatus } from './useStatus'; @@ -23,6 +25,9 @@ interface ILmmPolicyTopologyProps { 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; } @@ -40,7 +45,8 @@ const getClient = (): PublicClient => { }; export const LmmPolicyTopology: React.FC = (props) => { - const { pluginAddress, lidoDaoAddress, className } = props; + const { pluginAddress, lidoDaoAddress, selectedNodeAddress, className } = + props; const [topology, setTopology] = useState( undefined, ); @@ -70,7 +76,12 @@ export const LmmPolicyTopology: React.FC = (props) => { // Live status (budget amounts, paused flags, gate readings) — polled by // the vendored useStatus hook against the same RPC. useStatus expects a // client factory (it constructs its own retries) and a nullable topology. - const { state: statusState } = useStatus(getClient, topology ?? null); + // The hook's own snapshot powers two surfaces: the in-graph node labels + // (via `status` on TopologyView) and the StatusPanel cards rendered below. + const { state: statusState, refresh: refreshStatus } = useStatus( + getClient, + topology ?? null, + ); const statusSnapshot = statusState.kind === 'ready' ? statusState.snapshot : undefined; @@ -95,19 +106,30 @@ export const LmmPolicyTopology: React.FC = (props) => { } return ( -
    - +
    +
    + +
    + {/* Live cards: LMM DAO balances, Lido DAO LP, Budgets per + * dispatch, Stream remaining, PriceFloor gate status, CowSwap + * order count. Mirrors the bottom panel of the preview UI at + * localhost:5173 so the in-app deep-dive matches the live demo. + * StatusPanel manages its own resizable height. */} +
    ); }; diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx new file mode 100644 index 0000000000..0165df62bd --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx @@ -0,0 +1,385 @@ +'use client'; + +// Aragon-styled stand-in for the vendored `StepsView` (`./StepsView.tsx`), +// which renders Jordi's raw `.step` / `.flow-source` / `.flow-branches` CSS +// classes from `lido/preview/ui`. Same semantic content (per-leg status, +// transfers, external calls, token deltas) but drawn with the Tailwind + +// gov-ui-kit palette so it doesn't look like a foreign widget when embedded +// inside an Aragon `Dialog`. +// +// Kept colocated with the vendored UI so the import surface for callers is +// `@/modules/flow/components/lidoMoneyMachine/*` regardless of which view +// they pick. + +import classNames from 'classnames'; +import type { + FlowGraph, + Step, + StepStatus, + TokenBalance, +} from '@/shared/lidoPreview'; +import { formatAmount, shortAddress } from './format'; + +export interface ILmmSimulationCardsProps { + flow: FlowGraph; + className?: string; +} + +/** Vertical stack of per-strategy cards mirroring the on-chain dispatch. */ +export const LmmSimulationCards: React.FC = ({ + flow, + className, +}) => { + return ( +
      + {flow.steps.map((step) => ( +
    1. + +
    2. + ))} +
    + ); +}; + +// --------------------------------------------------------------------------- +// Status pill: maps the simulator's `StepStatus` onto the gov-ui-kit tone +// palette. Pre-tense ("Execute") rather than past-tense ("Executed") because +// the modal is showing what *would* happen if the user confirms. +// --------------------------------------------------------------------------- + +const STATUS_TONE: Record< + StepStatus, + { label: string; container: string; pill: string; border: string } +> = { + executed: { + label: 'Execute', + container: 'bg-success-50', + pill: 'bg-success-100 text-success-800', + border: 'border-success-200', + }, + 'no-op': { + label: 'No-op', + container: 'bg-neutral-50', + pill: 'bg-neutral-100 text-neutral-700', + border: 'border-neutral-100', + }, + 'skipped-paused': { + label: 'Skipped · paused', + container: 'bg-warning-50', + pill: 'bg-warning-100 text-warning-800', + border: 'border-warning-200', + }, + opaque: { + label: 'Opaque', + container: 'bg-warning-50', + pill: 'bg-warning-100 text-warning-800', + border: 'border-warning-200', + }, + 'downstream-opaque': { + label: 'Downstream opaque', + container: 'bg-warning-50', + pill: 'bg-warning-100 text-warning-700', + border: 'border-warning-200', + }, +}; + +const StepCard: React.FC<{ step: Step }> = ({ step }) => { + const tone = STATUS_TONE[step.status]; + const hasAction = + step.transfers.length > 0 || step.externalCalls.length > 0; + + return ( +
    +
    +
    + + #{step.index} + + + {strategyKindLabel(step.strategyRef.kind)} + +
    + + {tone.label} + +
    + + {step.reason && ( +

    + {step.reason} +

    + )} + + {hasAction && ( +
    + + +
    + )} + + {!hasAction && + step.status !== 'no-op' && + step.status !== 'skipped-paused' && ( +

    + (no actions) +

    + )} + + {hasBalanceChange(step) && ( +
    + +
    + )} +
    + ); +}; + +// --------------------------------------------------------------------------- +// Inline flow visualisation: where the value comes from (DAO budget) and the +// branches it fans out into (transfers / external-call consumes & produces). +// --------------------------------------------------------------------------- + +const SourceRow: React.FC<{ step: Step }> = ({ step }) => { + const { amount, token } = step.budget ?? {}; + return ( +
    + + + DAO + + {amount !== undefined && token && ( + + {formatAmount(amount, token.decimals, 4)}{' '} + + {token.symbol ?? ''} + + + )} +
    + ); +}; + +type Branch = { + kind: 'transfer' | 'burn' | 'call' | 'produce'; + amount: string; + token: string; + target: string; + sign?: '+' | '-'; +}; + +/** Tone for each branch — mirrors the verb's semantics so a glance at the + * colour tells you whether the leg adds or removes value from the DAO. */ +const BRANCH_TONE: Record = { + transfer: { label: 'text-neutral-600', sign: 'text-neutral-500' }, + burn: { label: 'text-warning-700', sign: 'text-warning-700' }, + produce: { label: 'text-success-700', sign: 'text-success-700' }, + call: { label: 'text-neutral-600', sign: 'text-neutral-500' }, +}; + +const BranchList: React.FC<{ step: Step }> = ({ step }) => { + const branches = branchesForStep(step); + if (branches.length === 0) { + return null; + } + return ( +
      + {branches.map((b, i) => ( +
    • + {/* Tree-ish prefix: a quiet `└─` glyph anchors each + * branch to the source row above without leaning on + * vendored CSS pseudo-elements. */} + + └─ + + + {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) { + // `description` looks like `wrap(...)`, `burn(...)`, etc — keep just + // the verb for the inline label so the branch row stays compact. + 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, + sign: '+', + }); + } + if (call.consumes.length === 0 && call.produces.length === 0) { + out.push({ + kind: 'call', + amount: '', + token: '', + target: call.description, + }); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Token-balance delta footer: `sym before → after ±delta`, one row per +// token whose balance actually moved. Skip same-value rows so the user only +// sees what changed (mirrors the upstream behaviour from Jordi's UI). +// --------------------------------------------------------------------------- + +const BalanceDeltaTable: React.FC<{ + before: TokenBalance[]; + after: TokenBalance[]; +}> = ({ before, after }) => { + 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); + const isNeg = it.delta < 0n; + 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); + }); +} + +// --------------------------------------------------------------------------- +// Human label for the strategy kind. Kept in sync with the corresponding +// switch in `StepsView.tsx`; any new strategy added in `@/shared/lidoPreview` +// should be added here too so the dialog doesn't fall back to raw kind ids. +// --------------------------------------------------------------------------- + +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 'Epoch transfer'; + case 'strategy.dispatch.lido.wrap': + return 'Lido · Wrap stETH → wstETH'; + case 'strategy.dispatch.lido.univ2-liquidity': + return 'Lido · UniV2 LP'; + case 'strategy.dispatch.lido.gated-cow-swap': + return 'Lido · Gated CoW swap'; + 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 index f412f187e6..2bfc932920 100644 --- a/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx @@ -173,6 +173,7 @@ export function TopologyView({ topology, status, lidoDaoAddress, + initialSelectedNodeAddress, }: { topology: TopologyGraph; status?: StatusSnapshot; @@ -181,6 +182,11 @@ export function TopologyView({ * → 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); @@ -217,10 +223,25 @@ export function TopologyView({ }; }, [topology, status, lidoDaoAddress]); - // Close any open details when the topology changes. + // 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(() => { - setSelected(null); - }, [topology]); + 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 (
    diff --git a/src/modules/flow/components/lidoMoneyMachine/actions.ts b/src/modules/flow/components/lidoMoneyMachine/actions.ts index dca05b7554..a7ddcf23f5 100644 --- a/src/modules/flow/components/lidoMoneyMachine/actions.ts +++ b/src/modules/flow/components/lidoMoneyMachine/actions.ts @@ -49,8 +49,15 @@ const dispatcherAbiWithErrors = [ ] as const; // anvil[0] private key — same one PrepareDemo + every operator script use. -const ANVIL_DEPLOYER_KEY: Hex = - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; +// 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)', diff --git a/src/modules/flow/demo/LmmDemoBanner.tsx b/src/modules/flow/demo/LmmDemoBanner.tsx index 98e7fd2073..1a2a1884e8 100644 --- a/src/modules/flow/demo/LmmDemoBanner.tsx +++ b/src/modules/flow/demo/LmmDemoBanner.tsx @@ -5,11 +5,75 @@ 'use client'; import classNames from 'classnames'; -import { LMM_DEMO_MODE, LMM_RPC_URL, useLmmManifest } from './lmmDemoConfig'; +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; } @@ -33,8 +97,8 @@ export const LmmDemoBanner: React.FC<{ className?: string }> = (props) => { DAO {manifest.lmm.dao.slice(0, 6)}… {manifest.lmm.dao.slice(-4)} · dispatcher{' '} - {manifest.lmm.dispatcher.slice(0, 6)}… - {manifest.lmm.dispatcher.slice(-4)} + {manifest.lmm.dispatcherPlugin.slice(0, 6)}… + {manifest.lmm.dispatcherPlugin.slice(-4)} )} {error && ( @@ -42,6 +106,16 @@ export const LmmDemoBanner: React.FC<{ className?: string }> = (props) => { 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 index 987e34e848..c18071b54c 100644 --- a/src/modules/flow/demo/lmmDaoOverride.ts +++ b/src/modules/flow/demo/lmmDaoOverride.ts @@ -23,6 +23,8 @@ import { PolicyStrategyType, } from '@/shared/api/daoService/domain'; import { + getLmmStrategies, + LMM_DEFAULT_METADATA, LMM_DEMO_MODE, LMM_FLOW_INDEXER_ENDPOINT, LMM_MANIFEST_URL, @@ -37,6 +39,36 @@ import { 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); @@ -44,15 +76,8 @@ const loadManifest = (): Promise => { if (inflightManifest) { return inflightManifest; } - inflightManifest = fetch(LMM_MANIFEST_URL, { cache: 'no-store' }) - .then((r) => { - if (!r.ok) { - throw new Error( - `manifest fetch ${r.status} from ${LMM_MANIFEST_URL}`, - ); - } - return r.json() as Promise; - }) + inflightManifest = fetchManifestRaw() + .then((raw) => JSON.parse(raw) as LmmManifest) .then((m) => { cachedManifest = m; inflightManifest = undefined; @@ -157,11 +182,19 @@ const buildIDaoFromManifest = ( id: `${network}-${address.toLowerCase()}`, address: address.toLowerCase(), network, - name: envio?.name ?? manifest.metadata.name, - description: envio?.description ?? manifest.metadata.description, + 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 ?? 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)], @@ -170,10 +203,15 @@ const buildIDaoFromManifest = ( members: 0, tvlUSD: '0', }, - links: (manifest.metadata.links ?? []).map((l) => ({ - name: l.label, - url: l.url, - })), + 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', }; @@ -183,7 +221,7 @@ const buildDispatcherAsIDaoPlugin = (manifest: LmmManifest): IDaoPlugin => ({ name: 'Capital Dispatcher', description: 'Multi-dispatch Capital Router plugin (Lido Money Machine demo)', - address: manifest.lmm.dispatcher, + address: manifest.lmm.dispatcherPlugin, daoAddress: manifest.lmm.dao, subdomain: 'capital-dispatcher', interfaceType: PluginInterfaceType.CAPITAL_DISTRIBUTOR, @@ -209,12 +247,12 @@ const buildPoliciesFromManifest = (manifest: LmmManifest): IDaoPolicy[] => { '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.dispatcher, + address: manifest.lmm.dispatcherPlugin, daoAddress: manifest.lmm.dao, interfaceType: PolicyInterfaceType.ROUTER, strategy: { type: PolicyStrategyType.MULTI_DISPATCH, - subRouters: manifest.lmm.strategies.map((s) => s.address), + subRouters: getLmmStrategies(manifest).map((s) => s.address), }, release: '1', build: '1', @@ -259,9 +297,12 @@ export const tryLmmDaoByEnsOverride = async ( return undefined; } // The LMM demo doesn't register a real ENS; we accept the manifest's - // metadata.name as an ENS-ish slug (lowercased) so URLs like + // 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.toLowerCase().replace(/\s+/g, '-'); + const slug = (manifest.metadata?.name ?? LMM_DEFAULT_METADATA.name) + .toLowerCase() + .replace(/\s+/g, '-'); if (ens.toLowerCase() !== slug) { return undefined; } @@ -310,7 +351,7 @@ export const tryLmmDaoPermissionsOverride = async ( } satisfies IPaginatedResponse; }; -// Used by lmmDemoConfig.useIsLmmDemoDao via a cached synchronous check. +// Used by useLmmManifest.useIsLmmDemoDao via a cached synchronous check. export const isLmmDemoDaoSync = (daoAddress: string | undefined): boolean => { if (!LMM_DEMO_MODE || !daoAddress || !cachedManifest) { return false; diff --git a/src/modules/flow/demo/lmmDemoConfig.ts b/src/modules/flow/demo/lmmDemoConfig.ts index 24c6af529c..61c32e1afa 100644 --- a/src/modules/flow/demo/lmmDemoConfig.ts +++ b/src/modules/flow/demo/lmmDemoConfig.ts @@ -1,11 +1,11 @@ -// LMM_DEMO_HACK: demo-mode flags + manifest loader for the Lido Money Machine -// demo. Everything in this file is a *temporary* short-circuit around the -// production Aragon backend / wagmi pipeline; remove the imports of this -// module to drop demo mode entirely. +// 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 { useEffect, useState } from 'react'; import type { Address } from 'viem'; /** `1` when the build should expose the LMM demo affordances. */ @@ -13,10 +13,23 @@ 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 caddy at `https:///manifest.json`. For local-first dev we + * 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`). */ @@ -48,179 +61,157 @@ export const LMM_RPC_URL: string = export const LMM_RPC_ALLOWLIST = [ 'localhost', '127.0.0.1', - // Production demo VM — adjust per environment. - 'lmm-demo.aragon-team.xyz', + // 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; - /** Aragon OSx + Capital Router deployment addresses. */ - aragon: { - daoFactory: Address; - pluginSetupProcessor: Address; - }; - /** The Lido Money Machine DAO + its plugins. */ + /** 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). */ - dispatcher: Address; - /** Embedded strategies, in dispatch order. */ - strategies: Array<{ - address: Address; - kind: 'WRAP' | 'UNIV2_LIQUIDITY' | 'GATED_COWSWAP' | string; - label: string; - }>; - /** Optional: budget/gate/epoch addresses for each strategy. */ - primitives?: { - budgets?: Array<{ address: Address; kind: string }>; - gates?: Array<{ address: Address; kind: string }>; - epochProviders?: Array<{ address: Address }>; + 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; }; }; - /** Hardcoded DAO metadata served by the manifest (no IPFS for the demo). */ - metadata: { - name: string; - description: string; + + /** 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. We compare this against the indexer's + * 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', + }, +]; + // --------------------------------------------------------------------------- -// React hook: load + cache the manifest +// 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. // --------------------------------------------------------------------------- -interface ManifestState { - manifest: LmmManifest | undefined; - error: Error | undefined; - loading: boolean; -} - -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; +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; -/** - * 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(); -}; +// --------------------------------------------------------------------------- +// 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 index e6230d27a6..cc2236d12d 100644 --- a/src/modules/flow/demo/safety.test.ts +++ b/src/modules/flow/demo/safety.test.ts @@ -7,16 +7,15 @@ import { assertForkRpc, manifestFingerprintCheck } from './safety'; const FAKE_MANIFEST: LmmManifest = { chainId: 1, - aragon: { - daoFactory: '0x1111111111111111111111111111111111111111', - pluginSetupProcessor: '0x2222222222222222222222222222222222222222', - }, lmm: { dao: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - dispatcher: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', - strategies: [], + dispatcherPlugin: '0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + strategies: { + wrap: '0xC1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1', + uniV2: '0xC2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2', + cowSwap: '0xC3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3', + }, }, - metadata: { name: 'LMM demo', description: '' }, }; describe('flow/demo/safety', () => { diff --git a/src/modules/flow/demo/safety.ts b/src/modules/flow/demo/safety.ts index 79eda46f91..20baaa656f 100644 --- a/src/modules/flow/demo/safety.ts +++ b/src/modules/flow/demo/safety.ts @@ -36,8 +36,8 @@ export const assertForkRpc = (rpcUrl: string = LMM_RPC_URL): void => { host === allowed.toLowerCase() ? true : // Subdomain match — only when the allowlist entry starts with a - // dot (".aragon-team.xyz" would match "lmm-demo.aragon-team.xyz"). - // We intentionally do NOT do open-ended suffix matching to keep + // 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()), ); 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/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx index 7129e7c3cd..1b6d0b3f1d 100644 --- a/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx +++ b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx @@ -3,6 +3,7 @@ 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'; @@ -25,6 +26,7 @@ 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'; @@ -51,6 +53,28 @@ export const FlowPolicyDetailPageClient: React.FC< 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 ; @@ -58,10 +82,6 @@ export const FlowPolicyDetailPageClient: React.FC< return ; } - const policy = data.policies.find( - (p) => p.id === decodedPolicyId || p.id === policyId, - ); - if (policy == null) { return (
    @@ -83,6 +103,18 @@ export const FlowPolicyDetailPageClient: React.FC< } 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), @@ -112,14 +144,29 @@ export const FlowPolicyDetailPageClient: React.FC<
    -
    -
    + {/* 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} @@ -138,19 +185,22 @@ export const FlowPolicyDetailPageClient: React.FC< /> Edit ↗ - {LMM_DEMO_MODE && }

    -
    + {/* 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'} @@ -158,6 +208,7 @@ export const FlowPolicyDetailPageClient: React.FC< ) : dispatchable ? ( <> - + {dispatchDisabled ? dispatchDisabledReason(policy) : 'Dispatches the pending amount to all recipients.'} @@ -177,17 +228,27 @@ export const FlowPolicyDetailPageClient: React.FC< ) : ( <> - + {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 && ( @@ -204,7 +265,10 @@ export const FlowPolicyDetailPageClient: React.FC< network={network} policy={policy} /> - +
    diff --git a/src/modules/flow/providers/flowDataProvider.tsx b/src/modules/flow/providers/flowDataProvider.tsx index 4b29c067fb..ec1a16bf84 100644 --- a/src/modules/flow/providers/flowDataProvider.tsx +++ b/src/modules/flow/providers/flowDataProvider.tsx @@ -20,6 +20,12 @@ import type { 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 { isLmmDemoDao } from '../demo/useLmmManifest'; import type { IFlowDaoData } from '../types'; import { useEnvioFlowData } from './useEnvioFlowData'; @@ -293,6 +299,27 @@ export const FlowDataProvider: React.FC = (props) => { 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. diff --git a/src/modules/flow/providers/useEnvioFlowData.ts b/src/modules/flow/providers/useEnvioFlowData.ts index a542660e73..bc258091e2 100644 --- a/src/modules/flow/providers/useEnvioFlowData.ts +++ b/src/modules/flow/providers/useEnvioFlowData.ts @@ -9,7 +9,11 @@ import { useEffect, useMemo } from 'react'; import { useProposalList } from '@/modules/governance/api/governanceService'; import { proposalUtils } from '@/modules/governance/utils/proposalUtils'; -import type { IDaoPolicy, Network } from '@/shared/api/daoService'; +import type { + IDaoPolicy, + ILinkedAccountSummary, + Network, +} from '@/shared/api/daoService'; import { useDao, useDaoPolicies } from '@/shared/api/daoService'; import { flowIndexerKeys, @@ -57,6 +61,13 @@ const getChainIdForNetwork = (network: string): number | null => { 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. @@ -100,7 +111,7 @@ export const useEnvioFlowData = ( ); const chainId = getChainIdForNetwork(network); - const linkedAccounts = dao?.linkedAccounts ?? []; + const linkedAccounts = dao?.linkedAccounts ?? EMPTY_LINKED_ACCOUNTS; const daoIds = useMemo( () => dao diff --git a/src/modules/flow/types.ts b/src/modules/flow/types.ts index 755a76accc..e6dda79ae7 100644 --- a/src/modules/flow/types.ts +++ b/src/modules/flow/types.ts @@ -403,6 +403,15 @@ export interface IFlowDaoData { * 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[]; } diff --git a/src/modules/flow/utils/envioFlowMapper.ts b/src/modules/flow/utils/envioFlowMapper.ts index b63cc30db4..83e1de89e3 100644 --- a/src/modules/flow/utils/envioFlowMapper.ts +++ b/src/modules/flow/utils/envioFlowMapper.ts @@ -52,6 +52,7 @@ import type { IFlowOrchestratorLeg, IFlowOrchestratorRun, IFlowPolicy, + IFlowPolicySubRouter, IFlowRecipient, IFlowRecipientAggregate, IFlowSwapPair, @@ -973,10 +974,56 @@ const mapPolicy = (params: { ? `${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) // --------------------------------------------------------------------------- @@ -1355,8 +1402,11 @@ export const buildFlowDataFromEnvio = ( policiesByAddress.set(p.address.toLowerCase(), p); } - // Pass 2: split into orchestrators vs leaves. + // 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)) { @@ -1370,6 +1420,12 @@ export const buildFlowDataFromEnvio = ( proposalByTxHash, }), ); + const mirror = policiesByAddress.get( + envioPolicy.pluginAddress.toLowerCase(), + ); + if (mirror) { + orchestratorPolicies.push(mirror); + } } else { const mapped = policiesByAddress.get( envioPolicy.pluginAddress.toLowerCase(), @@ -1410,6 +1466,7 @@ export const buildFlowDataFromEnvio = ( policies, groupedPolicies, orchestrators, + orchestratorPolicies, recipients: mapRecipientsAggregate( indexerData.RecipientAggregate, indexerData.Policy, diff --git a/src/shared/lidoPreview/render/reactFlow.ts b/src/shared/lidoPreview/render/reactFlow.ts index 84e4a2fe9c..5b818f8c37 100644 --- a/src/shared/lidoPreview/render/reactFlow.ts +++ b/src/shared/lidoPreview/render/reactFlow.ts @@ -114,7 +114,22 @@ export function toReactFlowGraph(topology: TopologyGraph): ReactFlowGraph { visitPlugin(topology.root, 'root', addNode, addEdge); } - return { nodes, edges }; + // 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( From ade8492893f62f88496b7f53650020c39b187526 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 27 May 2026 14:11:17 +0200 Subject: [PATCH 05/13] feat: Enhance dispatch dialogs and flow components for orchestrator strategies - Added accessibility labels to dispatch dialogs for improved user experience. - Updated capital flow dialog definitions to include hidden titles and descriptions for better compliance with accessibility standards. - Introduced new properties in IFlowDispatch to support multi-dispatch contexts, including leg index and kind. - Enhanced flow policy history and chart components to display orchestrator leg details and improve clarity in dispatch outcomes. - Implemented a refreshOracle function to update oracle timestamps, ensuring accurate data for demo scenarios. These changes improve the usability and accessibility of the flow management features, particularly for orchestrator strategies in the Lido Money Machine demo. --- src/assets/locales/en.json | 12 + .../capitalFlowDialogsDefinitions.ts | 19 +- .../flowPolicyCard/flowPolicyCard.tsx | 13 +- .../flowPolicyChart/flowPolicyChart.tsx | 36 ++- .../flowPolicyHistory/flowPolicyHistory.tsx | 117 +++++++-- .../flowPrimitives/flowTokenChip.tsx | 12 +- .../lidoMoneyMachine/LmmCheatsMenu.tsx | 29 ++- .../lidoMoneyMachine/LmmSimulationCards.tsx | 2 +- .../components/lidoMoneyMachine/actions.ts | 29 +++ src/modules/flow/demo/useLmmChainNow.ts | 133 ++++++++++ .../flowPolicyDetailPageClient.tsx | 14 +- .../flow/providers/flowDataProvider.tsx | 26 +- src/modules/flow/types.ts | 18 ++ src/modules/flow/utils/envioFlowMapper.ts | 242 +++++++++++++++++- 14 files changed, 650 insertions(+), 52 deletions(-) create mode 100644 src/modules/flow/demo/useLmmChainNow.ts diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 9070bac6bc..f128c1e325 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -746,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", @@ -769,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", @@ -796,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/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/flow/components/flowPolicyCard/flowPolicyCard.tsx b/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx index 6418efad58..e680901bb8 100644 --- a/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx +++ b/src/modules/flow/components/flowPolicyCard/flowPolicyCard.tsx @@ -286,10 +286,19 @@ interface IPolicyCardFooterProps { 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)}` + ? `Archived · uninstalled ${formatRelative(policy.uninstalledAt, nowMs)}` : 'Archived · uninstalled'; return {uninstalledLabel}; } @@ -308,7 +317,7 @@ const PolicyCardFooter: React.FC = (props) => { const cooldownActive = policy.status === 'cooldown' && policy.cooldown != null && - new Date(policy.cooldown.readyAt).getTime() > Date.now(); + new Date(policy.cooldown.readyAt).getTime() > nowMs; const pending = policy.pending; const dispatchLabel = pending diff --git a/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx b/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx index 06d5ba67ed..9e250101ec 100644 --- a/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx +++ b/src/modules/flow/components/flowPolicyChart/flowPolicyChart.tsx @@ -93,8 +93,20 @@ export const FlowPolicyChart: React.FC = (props) => { 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. - if (dispatch.status !== 'failed' && dispatch.status !== 'skipped') { + // 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 }); @@ -107,6 +119,11 @@ export const FlowPolicyChart: React.FC = (props) => { (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') { @@ -115,6 +132,13 @@ export const FlowPolicyChart: React.FC = (props) => { } 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'; @@ -125,7 +149,13 @@ export const FlowPolicyChart: React.FC = (props) => { // Compose a tooltip line that pairs IN→OUT for swap dispatches // and falls back to the single-leg form otherwise. let detail: string; - if (isSwap && dispatch.amountIn != null && dispatch.tokenIn) { + 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)}`; diff --git a/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx b/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx index c59dbe4a77..7ff3dba4a4 100644 --- a/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx +++ b/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx @@ -61,23 +61,85 @@ const filterLabels: Record = { 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: isFailed - ? 'Dispatch failed' - : `${policy.verb.charAt(0).toUpperCase()}${policy.verb.slice(1)}`, + title: titleBase, description: isFailed ? (dispatch.failureReason ?? 'Dispatch reverted.') - : dispatch.recipientsCount === 0 - ? 'No recipients' - : `${dispatch.recipientsCount} recipient${dispatch.recipientsCount === 1 ? '' : 's'}`, + : 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, @@ -202,25 +264,32 @@ export const FlowPolicyHistory: React.FC = (props) => { )}
    - {row.amount != null && row.tokenSymbol != null && ( - - - {formatFlowAmount( - row.amount, - row.tokenSymbol, - )}{' '} - {row.tokenSymbol} + {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.tokenSymbol} + + - - - )} + )} {row.proposalId != null && ( {row.proposalId} diff --git a/src/modules/flow/components/flowPrimitives/flowTokenChip.tsx b/src/modules/flow/components/flowPrimitives/flowTokenChip.tsx index 410cd8a42f..a150e3969a 100644 --- a/src/modules/flow/components/flowPrimitives/flowTokenChip.tsx +++ b/src/modules/flow/components/flowPrimitives/flowTokenChip.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; -import { FLOW_TOKENS, type FlowTokenSymbol } from '../../types'; +import type { FlowTokenSymbol } from '../../types'; +import { getTokenColor } from '../../utils/flowFormatters'; export interface IFlowTokenChipProps { token: FlowTokenSymbol; @@ -8,7 +9,12 @@ export interface IFlowTokenChipProps { export const FlowTokenChip: React.FC = (props) => { const { token, className } = props; - const meta = FLOW_TOKENS[token]; + // 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 ( = (props) => { {token} diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx index 644a8473f5..97fe1cfb5f 100644 --- a/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx @@ -17,6 +17,7 @@ import { type ActionContext, deriveAddressesFromManifest, dispatchAction, + refreshOracle, setEthPrice, setTargetEpoch, settleCowSwap, @@ -97,16 +98,32 @@ export const LmmCheatsMenu: React.FC = () => { { key: 'warp-1d', label: 'Warp +1 day', - description: 'Advance the fork clock by 24h, mine 1 block.', - run: runAction('warp-1d', () => warpAction(ctx, 24 * 60 * 60)), + 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.', - run: runAction('warp-7d', () => - warpAction(ctx, 7 * 24 * 60 * 60), - ), + 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)), }, ], [ diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx index 0165df62bd..c9b66eb52f 100644 --- a/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx @@ -375,7 +375,7 @@ function strategyKindLabel(kind: string): string { return 'Lido · Wrap stETH → wstETH'; case 'strategy.dispatch.lido.univ2-liquidity': return 'Lido · UniV2 LP'; - case 'strategy.dispatch.lido.gated-cow-swap': + case 'strategy.dispatch.lido.gated-cowswap': return 'Lido · Gated CoW swap'; case 'strategy.unknown': return 'Unknown'; diff --git a/src/modules/flow/components/lidoMoneyMachine/actions.ts b/src/modules/flow/components/lidoMoneyMachine/actions.ts index a7ddcf23f5..dfb55b4ab4 100644 --- a/src/modules/flow/components/lidoMoneyMachine/actions.ts +++ b/src/modules/flow/components/lidoMoneyMachine/actions.ts @@ -178,6 +178,35 @@ export async function warpAction( }); } +/** 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, 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/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx index 1b6d0b3f1d..2e3d7591e1 100644 --- a/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx +++ b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx @@ -47,7 +47,7 @@ export const FlowPolicyDetailPageClient: React.FC< IFlowPolicyDetailPageClientProps > = (props) => { const { network, addressOrEns, policyId } = props; - const { data, dispatchPolicy, getPendingDispatch, isError } = + const { data, dispatchPolicy, getPendingDispatch, isError, chainNowMs } = useFlowDataContext(); const { open } = useDialogContext(); const decodedPolicyId = decodeURIComponent(policyId); @@ -129,10 +129,14 @@ export const FlowPolicyDetailPageClient: React.FC< // 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() > Date.now(); + new Date(policy.cooldown.readyAt).getTime() > nowMs; const dispatchDisabled = cooldownActive || isDispatching; const dispatchLabel = isDispatching ? 'Dispatching…' @@ -222,7 +226,7 @@ export const FlowPolicyDetailPageClient: React.FC< {dispatchDisabled - ? dispatchDisabledReason(policy) + ? dispatchDisabledReason(policy, nowMs) : 'Dispatches the pending amount to all recipients.'} @@ -275,9 +279,9 @@ export const FlowPolicyDetailPageClient: React.FC< ); }; -const dispatchDisabledReason = (policy: IFlowPolicy): string => { +const dispatchDisabledReason = (policy: IFlowPolicy, nowMs: number): string => { if (policy.status === 'cooldown') { - return `Cooldown · ready ${policy.cooldown != null ? formatRelative(policy.cooldown.readyAt) : ''}.`; + return `Cooldown · ready ${policy.cooldown != null ? formatRelative(policy.cooldown.readyAt, nowMs) : ''}.`; } if (policy.status === 'paused') { return 'Paused for review. Resume via a governance proposal.'; diff --git a/src/modules/flow/providers/flowDataProvider.tsx b/src/modules/flow/providers/flowDataProvider.tsx index ec1a16bf84..d68fd06c41 100644 --- a/src/modules/flow/providers/flowDataProvider.tsx +++ b/src/modules/flow/providers/flowDataProvider.tsx @@ -25,6 +25,7 @@ import { networkDefinitions } from '@/shared/constants/networkDefinitions'; // 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 { isLmmDemoDao } from '../demo/useLmmManifest'; import type { IFlowDaoData } from '../types'; import { useEnvioFlowData } from './useEnvioFlowData'; @@ -103,6 +104,14 @@ export interface IFlowDataContext { 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; } const FlowDataContext = createContext(null); @@ -134,6 +143,10 @@ export const FlowDataProvider: React.FC = (props) => { 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(); + // 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 @@ -360,8 +373,17 @@ export const FlowDataProvider: React.FC = (props) => { 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(); - for (const policy of data.policies) { + 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()); @@ -453,6 +475,7 @@ export const FlowDataProvider: React.FC = (props) => { getPendingDispatch, toasts, dismissToast, + chainNowMs, }), [ resolvedDaoId, @@ -464,6 +487,7 @@ export const FlowDataProvider: React.FC = (props) => { getPendingDispatch, toasts, dismissToast, + chainNowMs, ], ); diff --git a/src/modules/flow/types.ts b/src/modules/flow/types.ts index e6dda79ae7..8bdf0f595c 100644 --- a/src/modules/flow/types.ts +++ b/src/modules/flow/types.ts @@ -96,6 +96,24 @@ export interface IFlowDispatch { 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 = diff --git a/src/modules/flow/utils/envioFlowMapper.ts b/src/modules/flow/utils/envioFlowMapper.ts index 83e1de89e3..15ce80be57 100644 --- a/src/modules/flow/utils/envioFlowMapper.ts +++ b/src/modules/flow/utils/envioFlowMapper.ts @@ -407,6 +407,131 @@ const pickSwapInLeg = ( 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 // --------------------------------------------------------------------------- @@ -459,6 +584,17 @@ const mapExecutionToDispatch = ( // 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, @@ -479,6 +615,8 @@ const mapExecutionToDispatch = ( // ../types to carry these optional fields. status: execution.skipped ? 'skipped' : 'ok', skippedReason: execution.skippedReason ?? undefined, + legIndex, + legKind, }; } @@ -498,6 +636,70 @@ const mapExecutionToDispatch = ( 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). + // Falling through to the generic `dominantOut ?? 0 USDC` fallback + // would print a misleading "0 USDC" row — surface a sentinel instead + // and let the History/Chart skip the amount block for these. + if (legKind === 'GATED_COWSWAP' || legKind === 'COWSWAP') { + 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, }; } @@ -515,6 +717,8 @@ const mapExecutionToDispatch = ( txHash: execution.txHash, proposalId: proposal?.proposalId, proposalSlug: proposal?.slug, + legIndex, + legKind, }; }; @@ -823,18 +1027,44 @@ const mapPolicy = (params: { ? 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 = - isSwap && dominantAll == null + 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)); - const policyTotalDistributed = - isSwap && dominantAll == null - ? (swapInAll?.amount ?? 0) - : (dominantAll?.amount ?? 0); + ('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) => From 3fd9326a8156fd52515e846d9741874dd54ee340 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 27 May 2026 16:10:30 +0200 Subject: [PATCH 06/13] feat: Update Lido Money Machine demo components and documentation - Enhanced the Lido Money Machine demo documentation to clarify production readiness and the integration of orchestrator strategies. - Updated flow components to improve the handling of dispatches, including the introduction of a money flow graph for visualizing dispatch outcomes. - Refactored the flow activity feed and recipients table to better represent orchestrator leg details and synthesized destination rows from the manifest. - Implemented various LMM_DEMO_HACKs to streamline demo functionality while preparing for production transitions. These changes enhance the usability and clarity of the Lido Money Machine demo, particularly in the context of orchestrator strategies. --- docs/lido-mmd-production-readiness.md | 5 + docs/lido-mmd-status.md | 16 + .../dispatchDialog/lmmDemoDispatchDialog.tsx | 37 +- .../flowActivityFeed/flowActivityFeed.tsx | 51 +- .../flow/components/flowKpiRow/flowKpiRow.tsx | 86 ++- .../flow/components/flowLede/flowLede.tsx | 56 +- .../flowMultiDispatchCard.tsx | 23 +- .../flowPolicyCard/flowPolicyCard.tsx | 11 +- .../flowPolicyChart/flowEventStyles.ts | 8 +- .../flowPolicyHistory/flowPolicyHistory.tsx | 11 +- .../flowPrimitives/flowStatusDot.tsx | 7 +- .../flowRecipientsTable.tsx | 179 +++++- .../lidoMoneyMachine/LmmPolicyTopology.tsx | 123 ++-- .../lidoMoneyMachine/LmmSimulationCards.tsx | 541 +++++++---------- .../lidoMoneyMachine/StatusPanel.tsx | 83 +-- .../lidoMoneyMachine/StatusView.tsx | 568 ++++++++---------- .../lidoMoneyMachine/TopologyView.tsx | 123 +++- .../lidoMoneyMachine/buildMoneyFlowGraph.ts | 227 +++++++ .../components/lidoMoneyMachine/layout.ts | 93 ++- .../components/lidoMoneyMachine/styles.css | 242 +------- src/modules/flow/demo/useLmmLiveSnapshot.ts | 142 +++++ .../flowPolicyDetailPageClient.tsx | 7 +- .../flow/providers/flowDataProvider.tsx | 41 ++ src/modules/flow/providers/flowSelectors.ts | 183 ++++++ src/modules/flow/utils/envioFlowMapper.ts | 55 +- 25 files changed, 1745 insertions(+), 1173 deletions(-) create mode 100644 src/modules/flow/components/lidoMoneyMachine/buildMoneyFlowGraph.ts create mode 100644 src/modules/flow/demo/useLmmLiveSnapshot.ts create mode 100644 src/modules/flow/providers/flowSelectors.ts diff --git a/docs/lido-mmd-production-readiness.md b/docs/lido-mmd-production-readiness.md index 7d0441addb..2b5da294ed 100644 --- a/docs/lido-mmd-production-readiness.md +++ b/docs/lido-mmd-production-readiness.md @@ -46,6 +46,11 @@ rg LMM_DEMO_HACK app/src capital-flow-indexer/src | **`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) diff --git a/docs/lido-mmd-status.md b/docs/lido-mmd-status.md index b7899c0a7b..363f706340 100644 --- a/docs/lido-mmd-status.md +++ b/docs/lido-mmd-status.md @@ -41,6 +41,22 @@ | 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. diff --git a/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx b/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx index a28ae3269d..a1f19989c6 100644 --- a/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx +++ b/src/modules/capitalFlow/dialogs/dispatchDialog/lmmDemoDispatchDialog.tsx @@ -4,7 +4,7 @@ // to), we drive `dispatch()` directly against the Anvil fork via viem + // `--auto-impersonate`. See `infra/lmm-demo/README.md` for the threat model. -import { Dialog } from '@aragon/gov-ui-kit'; +import { AlertCard, AlertInline, Dialog } from '@aragon/gov-ui-kit'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { @@ -187,10 +187,12 @@ export const LmmDemoDispatchDialog: React.FC<
    -
    - Demo mode · all writes go to {LMM_RPC_URL}. Real chains - are not touched. -
    + + Real chains are not touched. + {phase === 'preparing' && (
    @@ -198,24 +200,23 @@ export const LmmDemoDispatchDialog: React.FC<
    )} {simError && ( -
    - Simulation failed: {simError} -
    - Dispatch may still succeed on chain, but the - preview can't show what'll happen. -
    -
    + )} {flow && } {txError && ( -
    - Dispatch failed: {txError} -
    + )} {phase === 'success' && txHash && ( -
    - Dispatch confirmed. Tx hash: {txHash.slice(0, 10)}… -
    + )}
    diff --git a/src/modules/flow/components/flowActivityFeed/flowActivityFeed.tsx b/src/modules/flow/components/flowActivityFeed/flowActivityFeed.tsx index 13039b42fd..f628ffa608 100644 --- a/src/modules/flow/components/flowActivityFeed/flowActivityFeed.tsx +++ b/src/modules/flow/components/flowActivityFeed/flowActivityFeed.tsx @@ -3,6 +3,8 @@ 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, @@ -67,12 +69,45 @@ const eventTypeLabel: Record = { 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 => { +): string | undefined => { if (dispatch.recipientsCount === 0) { - return 'No recipients yet'; + return describeLeg(dispatch.legIndex, dispatch.legKind); } if (dispatch.recipientsCount === 1) { const top = dispatch.topRecipients[0]; @@ -87,7 +122,10 @@ const buildRecipientLabel = ( const buildRows = (data: IFlowDaoData, policyId?: string): IActivityRow[] => { const rows: IActivityRow[] = []; - for (const policy of data.policies) { + // 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; } @@ -148,6 +186,7 @@ export const FlowActivityFeed: React.FC = (props) => { className, } = props; + const now = useFlowNow(); const allRows = useMemo(() => buildRows(data, policyId), [data, policyId]); const [policyFilter, setPolicyFilter] = useState('all'); @@ -199,7 +238,7 @@ export const FlowActivityFeed: React.FC = (props) => { value={policyFilter} > - {data.policies.map((p) => ( + {getAllPolicies(data).map((p) => ( @@ -222,7 +261,7 @@ export const FlowActivityFeed: React.FC = (props) => { {rows.map((row) => (
  • - {formatRelative(row.at)} + {formatRelative(row.at, now)} {row.typeLabel} @@ -286,7 +325,7 @@ const DetailCell: React.FC<{ row: IActivityRow }> = ({ row }) => { return ( - {formatFlowAmount(row.amount, row.token)} {row.token} + {formatFlowAmount(row.amount, row.token)} diff --git a/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx b/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx index de4745173e..ea396172ba 100644 --- a/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx +++ b/src/modules/flow/components/flowKpiRow/flowKpiRow.tsx @@ -1,4 +1,12 @@ +'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'; @@ -12,7 +20,7 @@ interface IKpiItem { hint?: string; deltaLabel?: string; deltaTone?: 'up' | 'down' | 'flat'; - tone?: 'default' | 'critical' | 'success' | 'primary'; + tone?: 'default' | 'critical' | 'primary'; } const WEEK_MS = 7 * 24 * 60 * 60 * 1000; @@ -20,24 +28,54 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000; const toneClasses: Record, string> = { default: 'border-neutral-100', critical: 'border-critical-200 bg-critical-50', - success: 'border-success-200 bg-success-50', - primary: 'border-primary-200 bg-primary-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-success-700', + up: 'text-neutral-700', down: 'text-critical-700', flat: 'text-neutral-500', }; export const FlowKpiRow: React.FC = (props) => { const { data } = props; - // Per-dispatch / per-recipient totals stay scoped to leaf policies because - // every orchestrator run is already accounted for via its child legs. - const { policies } = data; + 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); - const readyPolicies = policies.filter((p) => p.status === 'ready'); + // 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) => { @@ -48,16 +86,22 @@ export const FlowKpiRow: React.FC = (props) => { (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 = - Object.entries(pendingByToken) - .map( - ([token, amount]) => - `${formatFlowAmount(amount as number, token as FlowTokenSymbol)} ${token}`, - ) - .join(' · ') || 'no queued amounts'; - - const now = Date.now(); - const dispatches7d = policies.reduce( + subtitleParts.length > 0 + ? subtitleParts.join(' + ') + : readyCount === 0 + ? 'nothing queued' + : 'no queued amounts'; + + const dispatches7d = allPolicies.reduce( (sum, policy) => sum + policy.dispatches.filter( @@ -67,7 +111,7 @@ export const FlowKpiRow: React.FC = (props) => { ).length, 0, ); - const dispatchesPrev7d = policies.reduce( + const dispatchesPrev7d = allPolicies.reduce( (sum, policy) => sum + policy.dispatches.filter((d) => { @@ -88,7 +132,7 @@ export const FlowKpiRow: React.FC = (props) => { ? 'same as prev 7d' : `${dispatchesDelta > 0 ? '+' : ''}${dispatchesDelta} vs prev 7d`; - const failed7d = policies.reduce( + const failed7d = allPolicies.reduce( (sum, policy) => sum + policy.dispatches.filter( @@ -98,7 +142,7 @@ export const FlowKpiRow: React.FC = (props) => { ).length, 0, ); - const failedEver = policies.reduce( + const failedEver = allPolicies.reduce( (sum, policy) => sum + policy.dispatches.filter((d) => d.status === 'failed').length, 0, @@ -128,7 +172,7 @@ export const FlowKpiRow: React.FC = (props) => { label: 'Failed · 7d', value: '0', hint: 'All operational', - tone: 'success', + tone: 'default', }, ]; diff --git a/src/modules/flow/components/flowLede/flowLede.tsx b/src/modules/flow/components/flowLede/flowLede.tsx index 9b93b7fa25..466abb5ed3 100644 --- a/src/modules/flow/components/flowLede/flowLede.tsx +++ b/src/modules/flow/components/flowLede/flowLede.tsx @@ -1,3 +1,11 @@ +'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'; @@ -9,9 +17,22 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000; export const FlowLede: React.FC = (props) => { const { data } = props; - const { dao, policies } = data; + 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 = policies.filter((p) => p.status === 'ready'); + 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> @@ -24,15 +45,25 @@ export const FlowLede: React.FC = (props) => { return acc; }, {}); - const pendingSummary = Object.entries(pendingByToken) - .map( - ([token, amount]) => - `${formatFlowAmount(amount as number, token as FlowTokenSymbol)} ${token}`, - ) - .join(' + '); + 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 = policies - .filter((p) => p.cooldown != null && p.status !== 'ready') + const cooldownPolicies = allPolicies + .filter( + (p) => + p.cooldown != null && + derivePolicyStatus(p, now).status !== 'ready', + ) .map((p) => p.cooldown!) .sort( (a, b) => @@ -40,8 +71,7 @@ export const FlowLede: React.FC = (props) => { ); const nextReady = cooldownPolicies[0]; - const now = Date.now(); - const failed7d = policies.reduce( + const failed7d = allPolicies.reduce( (sum, policy) => sum + policy.dispatches.filter( @@ -60,7 +90,7 @@ export const FlowLede: React.FC = (props) => { } if (nextReady != null) { narrativeParts.push( - `next autonomous dispatch ${formatRelative(nextReady.readyAt)}`, + `next autonomous dispatch ${formatRelative(nextReady.readyAt, now)}`, ); } if (failed7d > 0) { diff --git a/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx b/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx index aaceed42bf..052143f1c9 100644 --- a/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx +++ b/src/modules/flow/components/flowOrchestrators/flowMultiDispatchCard.tsx @@ -31,8 +31,10 @@ export interface IFlowMultiDispatchCardProps { const cardBorderByStatus: Record = { ready: 'border-primary-200', - live: 'border-success-200', - cooldown: 'border-warning-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', @@ -341,13 +343,18 @@ const FlowEmbeddedChainDiagram: React.FC = ({
  • ); +// 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-primary-200 bg-primary-50', - univ2Liquidity: 'border-violet-200 bg-violet-50', - gatedCowSwap: 'border-cyan-200 bg-cyan-50', - cowSwap: 'border-cyan-200 bg-cyan-50', + 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-warning-200 bg-warning-50', + epochTransfer: 'border-neutral-200 bg-neutral-0', burn: 'border-critical-200 bg-critical-50', unknown: 'border-neutral-200 bg-neutral-0', }; @@ -492,7 +499,7 @@ const RunLeg: React.FC = ({ leg, network, addressOrEns }) => { ? 'bg-critical-500' : isSkipped ? 'bg-warning-500' - : 'bg-success-500', + : 'bg-primary-400', )} /> = { ready: 'border-primary-200', - live: 'border-success-200', - cooldown: 'border-warning-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', @@ -306,7 +308,7 @@ const PolicyCardFooter: React.FC = (props) => { // Pull-based strategies (Claimer) can't be pushed from the UI. if (!isDispatchableStrategy(policy.strategy)) { return ( - + {policy.statusLabel || 'Open for claims'} ); @@ -422,11 +424,10 @@ const CooldownFooter: React.FC<{ }; const FooterChip: React.FC<{ - tone: 'success' | 'warning' | 'neutral'; + tone: 'warning' | 'neutral'; children: React.ReactNode; }> = ({ tone, children }) => { const toneClass = { - success: 'bg-success-100 text-success-800', warning: 'bg-warning-100 text-warning-800', neutral: 'bg-neutral-100 text-neutral-700', }[tone]; diff --git a/src/modules/flow/components/flowPolicyChart/flowEventStyles.ts b/src/modules/flow/components/flowPolicyChart/flowEventStyles.ts index ba2e9c05f3..147ee6e341 100644 --- a/src/modules/flow/components/flowPolicyChart/flowEventStyles.ts +++ b/src/modules/flow/components/flowPolicyChart/flowEventStyles.ts @@ -36,10 +36,14 @@ export const FLOW_EVENT_KIND_LABEL: Record = { * - governance / config: settings (indigo), proposal (violet), recipients (pink) */ export const FLOW_EVENT_KIND_TONE: Record = { - policyInstalled: '#10b981', + // 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: '#84cc16', + resumed: '#3b82f6', settingsUpdated: '#6366f1', proposalApplied: '#a855f7', recipientsUpdated: '#ec4899', diff --git a/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx b/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx index 7ff3dba4a4..cb2b83552e 100644 --- a/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx +++ b/src/modules/flow/components/flowPolicyHistory/flowPolicyHistory.tsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import { useMemo, useState } from 'react'; +import { useFlowNow } from '../../providers/flowDataProvider'; import type { FlowEventKind, IFlowPolicy } from '../../types'; import { formatFlowAmount, @@ -36,7 +37,9 @@ const eventTone: Record = { policyInstalled: 'bg-neutral-100 text-neutral-700', policyUninstalled: 'bg-critical-100 text-critical-800', paused: 'bg-warning-100 text-warning-800', - resumed: 'bg-success-100 text-success-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', @@ -166,6 +169,7 @@ const buildRows = (policy: IFlowPolicy): IHistoryRow[] => { 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(() => { @@ -244,7 +248,7 @@ export const FlowPolicyHistory: React.FC = (props) => { key={row.id} > - {formatRelative(row.at)} + {formatRelative(row.at, now)}
    @@ -284,8 +288,7 @@ export const FlowPolicyHistory: React.FC = (props) => { {formatFlowAmount( row.amount, row.tokenSymbol, - )}{' '} - {row.tokenSymbol} + )} diff --git a/src/modules/flow/components/flowPrimitives/flowStatusDot.tsx b/src/modules/flow/components/flowPrimitives/flowStatusDot.tsx index 07b41b717b..b0eda066e1 100644 --- a/src/modules/flow/components/flowPrimitives/flowStatusDot.tsx +++ b/src/modules/flow/components/flowPrimitives/flowStatusDot.tsx @@ -12,8 +12,11 @@ export interface IFlowStatusDotProps { const statusToClass: Record = { ready: 'bg-primary-400', - live: 'bg-success-500', - cooldown: 'bg-warning-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', diff --git a/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx b/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx index 7491b627fe..ae60c30880 100644 --- a/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx +++ b/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx @@ -3,6 +3,8 @@ 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, @@ -133,17 +135,147 @@ const formatAmounts = ( 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) { + if (d.amount > 0) { + amountsByToken[d.token] = + (amountsByToken[d.token] ?? 0) + d.amount; + } + if (d.amountIn != null && d.tokenIn && d.amountIn > 0) { + amountsByToken[d.tokenIn] = + (amountsByToken[d.tokenIn] ?? 0) + d.amountIn; + } + 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 leg', + 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 leg', + legKinds: ['UNIV2_LIQUIDITY'], + }); + const cowSwap = manifest.cowSwap?.settlement; + if (cowSwap) { + seeds.push({ + address: cowSwap, + name: 'CowSwap settlement', + role: 'router', + group: 'Buyback leg', + 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]); + }, [data, variant, policy, lmmSeeds]); const [query, setQuery] = useState(''); @@ -162,12 +294,13 @@ export const FlowRecipientsTable: React.FC = ( ); }, [allRows, variant, limit, query]); - const title = - variant === 'policy' - ? 'Recipients' - : variant === 'preview' - ? 'Top recipients' - : 'Recipients'; + const title = isDestinationsVariant + ? 'Destinations' + : variant === 'policy' + ? 'Recipients' + : variant === 'preview' + ? 'Top recipients' + : 'Recipients'; return (
    = ( - + {variant === 'policy' ? ( <> ) : ( @@ -278,10 +419,12 @@ export const FlowRecipientsTable: React.FC = ( {row.dispatchCount ?? 0} ) : ( @@ -312,7 +455,9 @@ export const FlowRecipientsTable: React.FC = ( className="py-6 text-center font-normal text-neutral-500 text-sm" colSpan={5} > - No recipients match the current filters. + {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/lidoMoneyMachine/LmmPolicyTopology.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx index d6d6a35c37..66fd3f2b4e 100644 --- a/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx @@ -1,27 +1,27 @@ -// LMM_DEMO_HACK: client-side wrapper that runs the vendored preview-lib -// `inspect()` against the demo anvil fork and renders the result via the -// vendored React Flow TopologyView. Production builds NEVER mount this: -// the FlowPolicyTree route guards on isLmmDemoDao() before importing. +// 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 { useEffect, useState } from 'react'; -import { - type Address, - createPublicClient, - http, - type PublicClient, -} from 'viem'; -import { mainnet } from 'viem/chains'; +import type { Address } from 'viem'; import { LMM_RPC_URL } from '@/modules/flow/demo/lmmDemoConfig'; -import { inspect, type TopologyGraph } from '@/shared/lidoPreview'; +import { useLmmManifest } from '@/modules/flow/demo/useLmmManifest'; +import { useFlowDataContext } from '@/modules/flow/providers/flowDataProvider'; import { StatusPanel } from './StatusPanel'; import { TopologyView } from './TopologyView'; -import { useStatus } from './useStatus'; interface ILmmPolicyTopologyProps { - /** DispatcherPlugin address (i.e. the top-level multi-dispatch policy). */ + /** 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; @@ -31,65 +31,19 @@ interface ILmmPolicyTopologyProps { className?: string; } -// 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; -}; - export const LmmPolicyTopology: React.FC = (props) => { - const { pluginAddress, lidoDaoAddress, selectedNodeAddress, className } = - props; - const [topology, setTopology] = useState( - undefined, - ); - const [error, setError] = useState(undefined); - - useEffect(() => { - let cancelled = false; - const run = async () => { - try { - const client = getClient(); - const result = await inspect(client, pluginAddress); - if (!cancelled) { - setTopology(result); - } - } catch (e) { - if (!cancelled) { - setError(e instanceof Error ? e : new Error(String(e))); - } - } - }; - void run(); - return () => { - cancelled = true; - }; - }, [pluginAddress]); - - // Live status (budget amounts, paused flags, gate readings) — polled by - // the vendored useStatus hook against the same RPC. useStatus expects a - // client factory (it constructs its own retries) and a nullable topology. - // The hook's own snapshot powers two surfaces: the in-graph node labels - // (via `status` on TopologyView) and the StatusPanel cards rendered below. - const { state: statusState, refresh: refreshStatus } = useStatus( - getClient, - topology ?? null, - ); - const statusSnapshot = - statusState.kind === 'ready' ? statusState.snapshot : undefined; + const { lidoDaoAddress, selectedNodeAddress, className } = props; + const { liveSnapshot } = useFlowDataContext(); + const { manifest } = useLmmManifest(); + const topology = liveSnapshot?.topology ?? null; + const statusSnapshot = liveSnapshot?.snapshot ?? undefined; + const error = liveSnapshot?.error; if (error) { return (
    - Topology inspection failed: {error.message} + Topology inspection failed: {error}
    ); @@ -106,30 +60,37 @@ export const LmmPolicyTopology: React.FC = (props) => { } return ( -
    +
    - {/* Live cards: LMM DAO balances, Lido DAO LP, Budgets per - * dispatch, Stream remaining, PriceFloor gate status, CowSwap - * order count. Mirrors the bottom panel of the preview UI at - * localhost:5173 so the in-app deep-dive matches the live demo. - * StatusPanel manages its own resizable height. */} - +
    + +
    ); }; diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx index c9b66eb52f..5274b7f344 100644 --- a/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/LmmSimulationCards.tsx @@ -1,22 +1,20 @@ 'use client'; -// Aragon-styled stand-in for the vendored `StepsView` (`./StepsView.tsx`), -// which renders Jordi's raw `.step` / `.flow-source` / `.flow-branches` CSS -// classes from `lido/preview/ui`. Same semantic content (per-leg status, -// transfers, external calls, token deltas) but drawn with the Tailwind + -// gov-ui-kit palette so it doesn't look like a foreign widget when embedded -// inside an Aragon `Dialog`. +// Aragon-styled per-leg cards for the LMM dispatch dialog. // -// Kept colocated with the vendored UI so the import surface for callers is -// `@/modules/flow/components/lidoMoneyMachine/*` regardless of which view -// they pick. +// 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, - TokenBalance, + TokenInfo, } from '@/shared/lidoPreview'; import { formatAmount, shortAddress } from './format'; @@ -25,361 +23,274 @@ export interface ILmmSimulationCardsProps { className?: string; } -/** Vertical stack of per-strategy cards mirroring the on-chain dispatch. */ +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, -}) => { - return ( -
      +}) => ( +
      +
        {flow.steps.map((step) => ( -
      1. +
      2. ))}
      - ); -}; - -// --------------------------------------------------------------------------- -// Status pill: maps the simulator's `StepStatus` onto the gov-ui-kit tone -// palette. Pre-tense ("Execute") rather than past-tense ("Executed") because -// the modal is showing what *would* happen if the user confirms. -// --------------------------------------------------------------------------- - -const STATUS_TONE: Record< - StepStatus, - { label: string; container: string; pill: string; border: string } -> = { - executed: { - label: 'Execute', - container: 'bg-success-50', - pill: 'bg-success-100 text-success-800', - border: 'border-success-200', - }, - 'no-op': { - label: 'No-op', - container: 'bg-neutral-50', - pill: 'bg-neutral-100 text-neutral-700', - border: 'border-neutral-100', - }, - 'skipped-paused': { - label: 'Skipped · paused', - container: 'bg-warning-50', - pill: 'bg-warning-100 text-warning-800', - border: 'border-warning-200', - }, - opaque: { - label: 'Opaque', - container: 'bg-warning-50', - pill: 'bg-warning-100 text-warning-800', - border: 'border-warning-200', - }, - 'downstream-opaque': { - label: 'Downstream opaque', - container: 'bg-warning-50', - pill: 'bg-warning-100 text-warning-700', - border: 'border-warning-200', - }, -}; + +
      +); const StepCard: React.FC<{ step: Step }> = ({ step }) => { - const tone = STATUS_TONE[step.status]; - const hasAction = - step.transfers.length > 0 || step.externalCalls.length > 0; - + const { ins, outs } = stepMoves(step); return ( -
      +
      -
      - +
      + #{step.index} - - {strategyKindLabel(step.strategyRef.kind)} + + {stepKindLabel(step.strategyRef.kind)}
      - {tone.label} + {statusChipLabel(step.status)}
      {step.reason && ( -

      +

      {step.reason}

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

      - (no actions) + No on-chain effects.

      )} - - {hasBalanceChange(step) && ( -
      - -
      - )} -
      + ); }; -// --------------------------------------------------------------------------- -// Inline flow visualisation: where the value comes from (DAO budget) and the -// branches it fans out into (transfers / external-call consumes & produces). -// --------------------------------------------------------------------------- - -const SourceRow: React.FC<{ step: Step }> = ({ step }) => { - const { amount, token } = step.budget ?? {}; - return ( -
      - - - DAO +const Column: React.FC<{ + title: string; + moves: ITokenMove[]; + placeholder: string; +}> = ({ title, moves, placeholder }) => ( +
      + + {title} + + {moves.length === 0 ? ( + + {placeholder} - {amount !== undefined && token && ( - - {formatAmount(amount, token.decimals, 4)}{' '} - - {token.symbol ?? ''} - - - )} -
      - ); -}; - -type Branch = { - kind: 'transfer' | 'burn' | 'call' | 'produce'; - amount: string; - token: string; - target: string; - sign?: '+' | '-'; -}; - -/** Tone for each branch — mirrors the verb's semantics so a glance at the - * colour tells you whether the leg adds or removes value from the DAO. */ -const BRANCH_TONE: Record = { - transfer: { label: 'text-neutral-600', sign: 'text-neutral-500' }, - burn: { label: 'text-warning-700', sign: 'text-warning-700' }, - produce: { label: 'text-success-700', sign: 'text-success-700' }, - call: { label: 'text-neutral-600', sign: 'text-neutral-500' }, -}; - -const BranchList: React.FC<{ step: Step }> = ({ step }) => { - const branches = branchesForStep(step); - if (branches.length === 0) { - return null; - } - return ( -
        - {branches.map((b, i) => ( -
      • - {/* Tree-ish prefix: a quiet `└─` glyph anchors each - * branch to the source row above without leaning on - * vendored CSS pseudo-elements. */} - - └─ - - + {moves.map((m, i) => ( +
      • - {b.amount} - {b.token ? ` ${b.token}` : ''} - - + {m.sign} + + {formatAmount(m.amount, m.token.decimals, 4)} + + + {m.token.symbol ?? + shortAddress(m.token.address)} + + + {m.label && ( + + {m.label} + )} - > - {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), +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, }); } - for (const call of step.externalCalls) { - // `description` looks like `wrap(...)`, `burn(...)`, etc — keep just - // the verb for the inline label so the branch row stays compact. - 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, - sign: '+', - }); - } - if (call.consumes.length === 0 && call.produces.length === 0) { - out.push({ - kind: 'call', - amount: '', - token: '', - target: call.description, - }); - } + const final = new Map(); + for (const b of flow.finalState.balances) { + final.set(b.token.address.toLowerCase(), { + token: b.token, + amount: b.amount, + }); } - return out; -} - -// --------------------------------------------------------------------------- -// Token-balance delta footer: `sym before → after ±delta`, one row per -// token whose balance actually moved. Skip same-value rows so the user only -// sees what changed (mirrors the upstream behaviour from Jordi's UI). -// --------------------------------------------------------------------------- - -const BalanceDeltaTable: React.FC<{ - before: TokenBalance[]; - after: TokenBalance[]; -}> = ({ before, after }) => { - 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 []; + 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; } - return [ - { token: b.token, before: b.amount, after: afterAmount, delta }, - ]; - }); - if (items.length === 0) { + const token = final.get(key)?.token ?? initial.get(key)!.token; + rows.push({ token, delta: after - before }); + } + if (rows.length === 0) { return null; } return ( -
      - {items.map((it) => { - const sym = it.token.symbol ?? shortAddress(it.token.address); - const isNeg = it.delta < 0n; - return ( -
      -
      - {sym} -
      -
      - {formatAmount(it.before, it.token.decimals, 4)}{' '} - {' '} - {formatAmount(it.after, it.token.decimals, 4)} -
      -
      +

      + Net effect on DAO +

      +
        + {rows.map((r) => { + const isNeg = r.delta < 0n; + const absDelta = isNeg ? -r.delta : r.delta; + return ( +
      • - {it.delta > 0n ? '+' : ''} - {formatAmount(it.delta, it.token.decimals, 4)} -
      -
      - ); - })} -
      + + {r.token.symbol ?? + shortAddress(r.token.address)} + + + {isNeg ? '−' : '+'} + {formatAmount(absDelta, r.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); - }); -} - -// --------------------------------------------------------------------------- -// Human label for the strategy kind. Kept in sync with the corresponding -// switch in `StepsView.tsx`; any new strategy added in `@/shared/lidoPreview` -// should be added here too so the dialog doesn't fall back to raw kind ids. -// --------------------------------------------------------------------------- - -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 'Epoch transfer'; - case 'strategy.dispatch.lido.wrap': - return 'Lido · Wrap stETH → wstETH'; - case 'strategy.dispatch.lido.univ2-liquidity': - return 'Lido · UniV2 LP'; - case 'strategy.dispatch.lido.gated-cowswap': - return 'Lido · Gated CoW swap'; - case 'strategy.unknown': - return 'Unknown'; - default: - return kind; - } -} diff --git a/src/modules/flow/components/lidoMoneyMachine/StatusPanel.tsx b/src/modules/flow/components/lidoMoneyMachine/StatusPanel.tsx index 051d01e76d..72d0f1cc98 100644 --- a/src/modules/flow/components/lidoMoneyMachine/StatusPanel.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/StatusPanel.tsx @@ -1,77 +1,12 @@ -// 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. +// 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. -// Bottom panel — now Status-only. Status auto-refreshes via `useStatus`; -// this component just provides the resizable chrome + the StatusView render -// surface. No outer tabs (the Simulation pane was demoted to a confirm -// modal), no close button (status is part of the page, not optional UI). - -import { useEffect, useRef, useState } from 'react'; import { StatusView } from './StatusView'; -import type { StatusState } from './useStatus'; - -const MIN_HEIGHT = 180; -const DEFAULT_HEIGHT = 360; - -export function StatusPanel({ - state, - onRefresh, -}: { - state: StatusState; - onRefresh: () => void; -}) { - const [height, setHeight] = useState(DEFAULT_HEIGHT); - const [dragging, setDragging] = useState(false); - const dragRef = useRef({ startY: 0, startHeight: 0 }); - - useEffect(() => { - if (!dragging) { - return; - } - function onMove(e: MouseEvent) { - const delta = dragRef.current.startY - e.clientY; - const max = Math.max(MIN_HEIGHT, window.innerHeight - 120); - const next = Math.max( - MIN_HEIGHT, - Math.min(max, dragRef.current.startHeight + delta), - ); - setHeight(next); - } - function onUp() { - setDragging(false); - } - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - const prevCursor = document.body.style.cursor; - const prevSelect = document.body.style.userSelect; - document.body.style.cursor = 'ns-resize'; - document.body.style.userSelect = 'none'; - return () => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - document.body.style.cursor = prevCursor; - document.body.style.userSelect = prevSelect; - }; - }, [dragging]); - - function onResizeStart(e: React.MouseEvent) { - e.preventDefault(); - dragRef.current = { startY: e.clientY, startHeight: height }; - setDragging(true); - } +import type { StatusSnapshot } from './useStatus'; - return ( -
      -
      - -
      - ); -} +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 index 769974974b..5474dc5926 100644 --- a/src/modules/flow/components/lidoMoneyMachine/StatusView.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/StatusView.tsx @@ -1,365 +1,269 @@ -// 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 view. Mirrors `just demo-status` but grouped into cards and -// without the awk-formatted ASCII boxes. Data comes from `useStatus` — -// every value is a fresh on-chain read keyed by the inspected topology, -// plus a `simulate()` result that fuels the "next dispatch" card. +// 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, StatusState } from './useStatus'; +import type { StatusSnapshot } from './useStatus'; -export function StatusView({ - state, - onRefresh, -}: { - state: StatusState; - onRefresh: () => void; -}) { - if (state.kind === 'idle') { - return
      Inspecting…
      ; - } - if (state.kind === 'loading') { - return
      Loading live state…
      ; - } - if (state.kind === 'error') { +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 ( -
      - Status fetch failed: {state.message} -
      - -
      +
      + +

      + Loading live state… +

      +
      ); } - - const snap = state.snapshot; return ( -
      -
      - - @ block {snap.block.toString()} · LMM DAO{' '} - {shortAddress(snap.dao)} - {state.refreshing && ( - - {' '} - · refreshing… - - )} - {state.refreshError && ( - - {' '} - · refresh failed - - )} - -
      -
      - - {snap.lp && } - - {snap.stream && } - {snap.gate && } - {snap.cowSwap && } -
      +
      + + + + + + + + + +
      ); -} +}; -// --- Cards ----------------------------------------------------------------- +const SectionHeader: React.FC<{ title: string; subtitle: string }> = ({ + title, + subtitle, +}) => ( +
      + + {title} + + + {subtitle} + +
      +); -function Card({ +const SectionCard: React.FC<{ title: string; children: ReactNode }> = ({ title, - hint, children, -}: { - title: string; - hint?: string; - children: ReactNode; -}) { - return ( -
      -
      -

      {title}

      - {hint && {hint}} -
      - {children} -
      - ); -} +}) => ( + +
      + + {title} + +
      +
      {children}
      +
      +); -function LmmDaoCard({ snap }: { snap: StatusSnapshot }) { - return ( - -
      - {snap.balances.map((b) => ( - - } - /> - ))} -
      -
      - ); -} +// --- Sections -------------------------------------------------------------- -// The LP recipient on the demo deployment is the Lido Agent, so the LP -// tokens minted by UniV2 land in the Lido DAO's vault — not in the LMM. -// Surfacing them under a separate card keeps the LMM card honest about -// what the LMM actually holds. -function LidoDaoCard({ snap }: { snap: StatusSnapshot }) { - const lp = snap.lp!; - return ( - - {lp.pair ? ( -
      - - } - /> -
      - ) : ( -

      - Pair not deployed yet — the strategy needs the pair to - exist. -

      - )} -
      - ); -} - -function BudgetsCard({ snap }: { snap: StatusSnapshot }) { - return ( - -
      - {snap.budgets.map((b, i) => ( - - {b.label}{' '} - - ({budgetKindShort(b.budgetKind)}) - - - } - key={`${b.strategyAddress}-${i}`} - v={ - - } +const BalancesSection: React.FC<{ snapshot: StatusSnapshot }> = ({ + snapshot, +}) => ( + + {snapshot.balances.map((b) => ( + + + + ))} + {snapshot.lp && ( + + {snapshot.lp.pair ? ( + - ))} -
      -
      - ); -} + ) : ( + + Pair not deployed yet + + )} + + )} + +); -function StreamCard({ snap }: { snap: StatusSnapshot }) { - const s = snap.stream!; - return ( - -
      - - {s.currentEpoch.toString()}} +const BudgetsSection: React.FC<{ snapshot: StatusSnapshot }> = ({ + snapshot, +}) => ( + + {snapshot.budgets.map((b, i) => ( + + - {s.targetEpoch.toString()}} - /> - - {s.remaining.toString()} ep{' '} - - {s.remaining < BigInt(s.floorEpochs) - ? '(below floor → constant drain)' - : '(above floor → drain grows as epochs tick)'} - - - } - /> - {s.floorEpochs} ep} /> -
      -
      - ); -} + + ))} + {snapshot.stream && ( + + + {snapshot.stream.remaining.toString()} ep · floor{' '} + {snapshot.stream.floorEpochs} + + + )} + +); -function GateCard({ snap }: { snap: StatusSnapshot }) { - const g = snap.gate!; - // Staleness is decided by the gate against `block.timestamp`, not the - // host wallclock — after a Warp the two diverge, and using Date.now() - // here would tell the user "fresh" while the gate actually fails closed. - const age = - g.updatedAt !== null - ? Math.max(0, Number(snap.blockTimestamp - g.updatedAt)) - : null; - const stale = - age !== null && g.maxStaleness > 0n && BigInt(age) > g.maxStaleness; - // The oracle returns the price scaled to tokenB's decimals (USDC = 6 in - // the demo: 3_000_000_000 reads "$3000.00"). We don't fetch TokenInfo - // for tokenB at inspect time, so hardcode 6 here — TODO: generalise via - // `erc20.decimals()` if a non-USDC pair ever ships. - const PRICE_DECIMALS = 6; - return ( - -
      - - {g.passes ? 'open' : 'closed'} - - } - /> - - - {formatAmount(g.price, PRICE_DECIMALS)} - {' '} - - threshold{' '} - - {formatAmount( - g.threshold, - PRICE_DECIMALS, - )} - - - - ) : ( - unreadable - ) - } - /> - - {age}s ago{' '} - - {stale - ? '(stale — gate fails closed)' - : `(stale at ${g.maxStaleness.toString()}s)`} - - - ) : ( - - ) - } +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() + : '—'} + + , + + -
      -
      - ); -} - -function CowSwapCard({ snap }: { snap: StatusSnapshot }) { - const cs = snap.cowSwap!; + , + ); + } + if (items.length === 0) { + return ( +

      + No execution conditions configured for this dispatcher. +

      + ); + } return ( - -
      - {cs.ordersPlaced.toString()} - ) : ( - - ) - } - /> - - } - /> -
      -
      + + {items} + ); -} +}; -// --- Small UI atoms -------------------------------------------------------- +// --- Atoms ----------------------------------------------------------------- -function Row({ k, v }: { k: ReactNode; v: ReactNode }) { - return ( - <> -
      {k}
      -
      {v}
      - - ); -} - -function Amount({ - amount, - decimals, - token, - muted, -}: { +const Amount: React.FC<{ amount: bigint; decimals: number | null; token: string | null; - muted?: boolean; -}) { - return ( - - {formatAmount(amount, decimals, 4)} - {token && {token}} - - ); -} +}> = ({ amount, decimals, token }) => ( + + {formatAmount(amount, decimals, 4)} + {token && ( + {token} + )} + +); -function Mono({ children }: { children: ReactNode }) { - return {children}; -} +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); diff --git a/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx b/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx index 2bfc932920..9954f117c2 100644 --- a/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx @@ -3,14 +3,17 @@ // Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring // of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. +import { Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; import { Background, Controls, type Edge, MiniMap, type Node, + Panel, ReactFlow, } from '@xyflow/react'; +import classNames from 'classnames'; import { useEffect, useMemo, useState } from 'react'; import '@xyflow/react/dist/style.css'; import type { Address } from 'viem'; @@ -19,11 +22,15 @@ import { type TopologyGraph, toReactFlowGraph, } from '@/shared/lidoPreview'; +import type { LmmManifest } from '../../demo/lmmDemoConfig'; +import { buildMoneyFlowGraph } from './buildMoneyFlowGraph'; import { formatAmount, shortAddress } from './format'; import { layout } from './layout'; import { NodeDetails } from './NodeDetails'; import type { StatusSnapshot } from './useStatus'; +export type TopologyMode = 'wiring' | 'moneyFlow'; + // --- Live-value helpers (read from `status` and format for a node label) --- function liveBudgetLineForStrategy( @@ -92,15 +99,6 @@ function budgetAmountAndToken( return line ? `${line} ${tok}` : tok; } -function gateStateLabel( - status: StatusSnapshot | undefined, -): string | undefined { - if (!status?.gate) { - return undefined; - } - return `gate ${status.gate.passes ? 'open' : 'closed'}`; -} - /** 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 @@ -174,6 +172,7 @@ export function TopologyView({ status, lidoDaoAddress, initialSelectedNodeAddress, + manifest, }: { topology: TopologyGraph; status?: StatusSnapshot; @@ -187,10 +186,14 @@ export function TopologyView({ * NodeDetails sidebar. Used by the dashboard orchestrator chip click * to land the user directly on the strategy they tapped. */ initialSelectedNodeAddress?: string; + /** Optional LMM manifest — when present the Money Flow toggle and node + * labels can render friendly names for known addresses. */ + manifest?: LmmManifest; }) { const [selected, setSelected] = useState(null); + const [mode, setMode] = useState('wiring'); - const { nodes, edges, byId } = useMemo(() => { + const wiring = useMemo(() => { const raw = toReactFlowGraph(topology); if (lidoDaoAddress) { const lidoId = 'lido-dao'; @@ -211,7 +214,7 @@ export function TopologyView({ type: 'owns', }); } - const laidOut = layout(raw); + const laidOut = layout(raw, { rankdir: 'TB' }); const map = new Map(); for (const n of laidOut.nodes) { map.set(n.id, n); @@ -223,6 +226,31 @@ export function TopologyView({ }; }, [topology, status, lidoDaoAddress]); + const moneyFlow = useMemo(() => { + const raw = buildMoneyFlowGraph(status?.nextDispatch ?? null, manifest); + if (raw.nodes.length === 0) { + return { nodes: [], edges: [], byId: new Map() }; + } + const laidOut = layout(raw, { rankdir: 'LR', ranksep: 140 }); + const map = new Map(); + for (const n of laidOut.nodes) { + map.set(n.id, n as GraphNode); + } + return { + nodes: laidOut.nodes.map(toMoneyFlowReactFlowNode), + edges: laidOut.edges.map(toReactFlowEdge), + byId: map, + }; + }, [status?.nextDispatch, manifest]); + + const isMoneyFlow = mode === 'moneyFlow'; + const moneyFlowEmpty = isMoneyFlow && moneyFlow.nodes.length === 0; + const moneyFlowSkipReason = isMoneyFlow + ? status?.nextDispatchError + : undefined; + const view = isMoneyFlow ? moneyFlow : wiring; + const { nodes, edges, byId } = view; + // 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 @@ -243,12 +271,28 @@ export function TopologyView({ setSelected(match ?? null); }, [initialSelectedNodeAddress, byId]); + const modeToggle = ( + +
      + v != null && setMode(v as TopologyMode)} + value={mode} + > + + + +
      +
      + ); + return ( -
      +
      { const original = byId.get(node.id); @@ -259,11 +303,28 @@ export function TopologyView({ onPaneClick={() => setSelected(null)} proOptions={{ hideAttribution: true }} > + {modeToggle} - - + {!moneyFlowEmpty && } + {!moneyFlowEmpty && ( + + )} + {moneyFlowEmpty && ( + + + No flow available + + + {moneyFlowSkipReason ?? + 'simulate() did not return any executed legs — wait for the live snapshot to refresh.'} + + + )} - {selected && ( + {selected && !isMoneyFlow && ( setSelected(null)} @@ -274,6 +335,34 @@ export function TopologyView({ ); } +// --- Money-flow node renderer -------------------------------------------- + +function toMoneyFlowReactFlowNode(node: GraphNode): Node { + const label = (node.data as { label?: string }).label ?? node.type; + const reason = (node.data as { reason?: string }).reason; + const skipped = node.type === 'money-step-skipped'; + // Reuse the wiring graph's `.rf-*` palette so the toggle doesn't read as + // two unrelated graphs: DAO node = `.rf-dao`, address/contract nodes = + // `.rf-recipient`, skipped steps = `.rf-strategy-unknown` (dashed + // neutral). Skip the dedicated `.rf-money-*` classes from earlier + // revisions — they were never declared in `styles.css`. + const sharedClass = + node.type === 'money-dao' + ? 'rf-dao' + : skipped + ? 'rf-strategy-unknown' + : 'rf-recipient'; + return { + id: node.id, + type: 'default', + position: node.position, + data: { + label: reason ? `${label}\n${reason}` : label, + }, + className: classNames('rf-node', sharedClass), + }; +} + // --- Conversion from our typed GraphNode to React Flow's Node -------------- function toReactFlowNode(node: GraphNode, status?: StatusSnapshot): Node { diff --git a/src/modules/flow/components/lidoMoneyMachine/buildMoneyFlowGraph.ts b/src/modules/flow/components/lidoMoneyMachine/buildMoneyFlowGraph.ts new file mode 100644 index 0000000000..41259336fe --- /dev/null +++ b/src/modules/flow/components/lidoMoneyMachine/buildMoneyFlowGraph.ts @@ -0,0 +1,227 @@ +// LMM_DEMO_HACK: money-flow-from-simulate. Build a ReactFlow-compatible graph +// describing what `dispatch()` would actually do at the current chain head, +// driven by the vendored `simulate()`'s `FlowGraph`. Mirrors the dispatch +// dialog's per-leg breakdown but laid out horizontally so the operator can +// see the value path at a glance. +// +// Production replacement: emit `ExecutionTransfer` rows with a +// `flowDirection: in|out` field (or a dedicated `MoneyFlowEdge` entity) +// from the indexer so this can be reconstructed from historical data +// without re-running `simulate()` in the browser. See +// docs/lido-mmd-status.md row `money-flow-from-simulate`. + +import type { Address } from 'viem'; +import type { FlowGraph, TokenInfo } from '@/shared/lidoPreview'; +import type { LmmManifest } from '../../demo/lmmDemoConfig'; +import type { ReactFlowGraph } from '@/shared/lidoPreview'; + +const DAO_NODE = 'money/dao'; + +const sameAddress = (a: string | undefined, b: string | undefined): boolean => + a != null && b != null && a.toLowerCase() === b.toLowerCase(); + +/** Friendly node label for a known address from the manifest. */ +const labelForAddress = ( + address: Address, + manifest: LmmManifest | undefined, +): string => { + if (manifest == null) { + return shortAddress(address); + } + if (sameAddress(address, manifest.lmm.dao)) { + return 'LMM DAO'; + } + if (sameAddress(address, manifest.lido?.agent)) { + return 'Lido Agent · LP recipient'; + } + if (sameAddress(address, manifest.cowSwap?.settlement)) { + return 'CowSwap settlement'; + } + if (sameAddress(address, manifest.lido?.wstETH)) { + return 'wstETH contract'; + } + if (sameAddress(address, manifest.lido?.stETH)) { + return 'stETH contract'; + } + if (sameAddress(address, manifest.lido?.ldo)) { + return 'LDO token'; + } + if (sameAddress(address, manifest.lido?.uniV2Router)) { + return 'UniV2 router'; + } + if (sameAddress(address, manifest.lmm.dispatcherPlugin)) { + return 'DispatcherPlugin'; + } + return shortAddress(address); +}; + +const shortAddress = (a: string): string => `${a.slice(0, 6)}…${a.slice(-4)}`; + +const formatTokenAmount = (amount: bigint, token: TokenInfo): string => { + const decimals = token.decimals ?? 18; + if (decimals <= 0) { + return amount.toString(); + } + const base = BigInt(10) ** BigInt(decimals); + const whole = amount / base; + const frac = amount % base; + if (frac === 0n) { + return whole.toString(); + } + const fracStr = frac.toString().padStart(decimals, '0').slice(0, 4); + const trimmed = fracStr.replace(/0+$/, ''); + return trimmed.length > 0 ? `${whole}.${trimmed}` : whole.toString(); +}; + +const edgeLabel = (amount: bigint, token: TokenInfo): string => { + const sym = token.symbol ?? shortAddress(token.address); + return `${formatTokenAmount(amount, token)} ${sym}`; +}; + +const stepLabel = (kind: string): string => { + switch (kind) { + case 'strategy.dispatch.lido.wrap': + return 'Wrap'; + case 'strategy.dispatch.lido.univ2-liquidity': + return 'Add LP'; + case 'strategy.dispatch.lido.gated-cowswap': + return 'CowSwap'; + case 'strategy.dispatch.transfer': + return 'Transfer'; + case 'strategy.dispatch.burn': + return 'Burn'; + case 'strategy.dispatch.epoch-transfer': + return 'Epoch transfer'; + default: + return kind; + } +}; + +/** + * Build a money-flow `ReactFlowGraph` from a simulated FlowGraph. Returns + * a structurally-empty graph when the simulator failed or every step is a + * no-op — callers should render a "No flow available" hint in that case. + */ +export const buildMoneyFlowGraph = ( + flow: FlowGraph | null, + manifest: LmmManifest | undefined, +): ReactFlowGraph => { + if (flow == null) { + return { nodes: [], edges: [] }; + } + + const nodes = new Map(); + const edges: ReactFlowGraph['edges'] = []; + const nodeIdForAddress = new Map(); + + const ensureAddressNode = ( + address: Address, + type = 'money-account', + ): string => { + const key = address.toLowerCase(); + const existing = nodeIdForAddress.get(key); + if (existing != null) { + return existing; + } + const id = `money/addr/${key}`; + nodes.set(id, { + id, + type, + data: { + address, + label: labelForAddress(address, manifest), + }, + position: { x: 0, y: 0 }, + }); + nodeIdForAddress.set(key, id); + return id; + }; + + // Anchor DAO node first so dagre puts it leftmost. + nodes.set(DAO_NODE, { + id: DAO_NODE, + type: 'money-dao', + data: { + address: flow.initialState.dao, + label: labelForAddress(flow.initialState.dao, manifest), + }, + position: { x: 0, y: 0 }, + }); + nodeIdForAddress.set(flow.initialState.dao.toLowerCase(), DAO_NODE); + + let edgeCounter = 0; + const pushEdge = ( + source: string, + target: string, + label: string, + kind: string, + ): void => { + edges.push({ + id: `money/edge/${edgeCounter++}`, + source, + target, + label, + type: kind, + }); + }; + + for (const step of flow.steps) { + if (step.status !== 'executed') { + // Surface non-executed legs as a single "skipped" edge from DAO + // to a virtual step node so the operator sees why no value + // flowed through that path. + const stepNodeId = `money/step/${step.index}`; + nodes.set(stepNodeId, { + id: stepNodeId, + type: 'money-step-skipped', + data: { + label: `${stepLabel(step.strategyRef.kind)} · skipped`, + reason: step.reason, + }, + position: { x: 0, y: 0 }, + }); + pushEdge(DAO_NODE, stepNodeId, step.reason ?? 'skipped', 'skipped'); + continue; + } + + // 1) Transfers — explicit ERC-20 movements from DAO → recipient. + for (const t of step.transfers) { + const targetId = sameAddress(t.to, flow.initialState.dao) + ? DAO_NODE + : ensureAddressNode(t.to); + pushEdge( + DAO_NODE, + targetId, + edgeLabel(t.amount, t.token), + 'transfer', + ); + } + + // 2) External calls — DAO → contract (consumes) and contract → DAO + // (produces). Surface the call's verb (`wrap`, `swap`) as a + // short label on each edge so the operator can tell apart a + // Wrap call from a UniV2 swap. + for (const call of step.externalCalls) { + const verb = call.description.split('(')[0] || 'call'; + const contractId = ensureAddressNode(call.to); + for (const c of call.consumes) { + pushEdge( + DAO_NODE, + contractId, + `${verb} · ${edgeLabel(c.amount, c.token)}`, + 'call-consumes', + ); + } + for (const p of call.produces) { + pushEdge( + contractId, + DAO_NODE, + `${verb} · +${edgeLabel(p.amount, p.token)}`, + 'call-produces', + ); + } + } + } + + return { nodes: Array.from(nodes.values()), edges }; +}; diff --git a/src/modules/flow/components/lidoMoneyMachine/layout.ts b/src/modules/flow/components/lidoMoneyMachine/layout.ts index e957d59b74..ca72e3fbe8 100644 --- a/src/modules/flow/components/lidoMoneyMachine/layout.ts +++ b/src/modules/flow/components/lidoMoneyMachine/layout.ts @@ -2,6 +2,10 @@ // 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'; @@ -9,18 +13,34 @@ 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; +} + /** - * Top-to-bottom dagre layout. React Flow's default node handles are at - * top/bottom edges, so a TB layout lines them up without needing custom - * node components. + * 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): ReactFlowGraph { +export function layout( + graph: ReactFlowGraph, + options: ILayoutOptions = {}, +): ReactFlowGraph { + const { rankdir = 'TB', nodesep = 40, ranksep = 100 } = options; const g = new dagre.graphlib.Graph(); g.setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: 'TB', nodesep: 40, ranksep: 100 }); + // `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 }); @@ -31,6 +51,69 @@ export function layout(graph: ReactFlowGraph): ReactFlowGraph { 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 strategies + // ordered left→right by on-chain index (`[0]`, `[1]`, `[2]`), which + // `toReactFlowGraph` emits as `composes`-edge labels. + // + // We rebuild the cross-axis coordinate per rank by sorting nodes that + // carry a `[N]` order index, preserving the rank's existing extent so + // the layout doesn't visually jump. + const horizontal = rankdir === 'LR'; + const orderByTarget = new Map(); + for (const e of graph.edges) { + if (e.type !== 'composes' && !(e.label && /^\[\d+\]$/.test(e.label))) { + continue; + } + const m = (e.label ?? '').match(/^\[(\d+)\]$/); + if (m) { + orderByTarget.set(e.target, Number(m[1])); + } + } + + if (orderByTarget.size > 0) { + const buckets = new Map< + number, + Array<{ id: string; cross: number; order: number }> + >(); + for (const n of graph.nodes) { + const order = orderByTarget.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); + list.sort((a, b) => a.order - b.order); + 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); diff --git a/src/modules/flow/components/lidoMoneyMachine/styles.css b/src/modules/flow/components/lidoMoneyMachine/styles.css index c4c1a9c8f3..a89ab73b3a 100644 --- a/src/modules/flow/components/lidoMoneyMachine/styles.css +++ b/src/modules/flow/components/lidoMoneyMachine/styles.css @@ -1007,240 +1007,14 @@ li:has(.flow-target-produce) .flow-amount { cursor: not-allowed; } -/* --- Status view --------------------------------------------------------- */ -.status { - padding: 12px 16px 16px; - overflow: auto; - height: 100%; -} -.status-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; - font-family: ui-monospace, monospace; - font-size: 12px; - color: #475569; -} -.status-toolbar button { - padding: 4px 10px; - font-size: 12px; -} -.status-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 12px; -} -.status-card { - border: 1px solid #e2e8f0; - border-radius: 8px; - background: #ffffff; - overflow: hidden; -} -.status-card header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - padding: 8px 12px; - border-bottom: 1px solid #e2e8f0; -} -/* Soft per-concern tints — pick the matching family the topology nodes - already use, then drop to a low-saturation tint so the card reads as - "kind of this". Order in .status-grid is stable, so card-N selectors - work even without per-card classes. */ -.status-card:nth-child(1) { - background: #fefce8; -} /* Balances — yellow family */ -.status-card:nth-child(1) header { - background: #fdfdb8; -} -.status-card:nth-child(2) { - background: #ecfeff; -} /* Budgets — teal family (matches Lido strategy nodes) */ -.status-card:nth-child(2) header { - background: #cffafe; -} -.status-card:nth-child(3) { - background: #ecfeff; -} /* Stream — same teal as Budgets, it's the wstETH/stream lane */ -.status-card:nth-child(3) header { - background: #cffafe; -} -.status-card:nth-child(4) { - background: #fdf2f8; -} /* Gate — pink family (matches PriceFloorGate node) */ -.status-card:nth-child(4) header { - background: #fce7f3; -} -.status-card:nth-child(5) { - background: #faf5ff; -} /* CowSwap — purple family */ -.status-card:nth-child(5) header { - background: #f3e8ff; -} -.status-card:nth-child(6) { - background: #f0fdf4; -} /* Next dispatch — green family */ -.status-card:nth-child(6) header { - background: #dcfce7; -} -.status-card h4 { - margin: 0; - font-size: 13px; - font-weight: 600; - color: #0f172a; - text-transform: uppercase; - letter-spacing: 0.04em; -} -.status-hint { - font-size: 11px; - color: #64748b; - font-family: ui-monospace, monospace; -} -.status-card .kv { - margin: 0; - padding: 10px 12px; - display: grid; - grid-template-columns: minmax(110px, max-content) 1fr; - column-gap: 12px; - row-gap: 6px; - font-size: 13px; - align-items: baseline; -} -.status-card dt { - color: #475569; -} -.status-card dd { - margin: 0; - color: #0f172a; - text-align: right; -} -.status-card .muted { - color: #94a3b8; -} -.status-card .small { - font-size: 11px; -} -.status-card .mono { - font-family: ui-monospace, monospace; -} -.status-card .warn { - color: #b45309; -} -.status-card p { - margin: 0; - padding: 12px; -} - -/* Pills (gate open/closed, would-fire / skip). */ -.pill, -.pill-open, -.pill-closed { - display: inline-block; - padding: 1px 8px; - font-size: 11px; - font-weight: 600; - border-radius: 999px; - text-transform: lowercase; - letter-spacing: 0.02em; -} -.pill-open { - background: #dcfce7; - color: #166534; -} -.pill-closed { - background: #fee2e2; - color: #991b1b; -} -.pill-opaque { - background: #e2e8f0; - color: #475569; -} - -/* Balances card sub-sections (DAO vs LP). */ -.balances-group + .balances-group { - border-top: 1px dashed #e2e8f0; -} -.balances-section-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: #475569; - padding: 8px 12px 0; -} -.balances-empty { - padding: 6px 12px 12px !important; - font-size: 12px; -} - -/* Next-dispatch step cards (inside the Status pane). */ -.status-steps { - list-style: none; - margin: 0; - padding: 8px; - display: flex; - flex-direction: column; - gap: 6px; - font-size: 13px; -} -.status-step { - border: 1px solid #e2e8f0; - border-radius: 6px; - background: #fafafa; - padding: 6px 10px; - display: flex; - flex-direction: column; - gap: 4px; -} -.status-step-executed { - background: #f0fdf4; - border-color: #bbf7d0; -} -.status-step-no-op, -.status-step-skipped-paused { - background: #fff7ed; - border-color: #fed7aa; -} -.status-step-opaque, -.status-step-downstream-opaque { - background: #f1f5f9; - border-color: #cbd5e1; -} -.status-step-head { - display: flex; - align-items: baseline; - gap: 8px; - flex-wrap: wrap; -} -.status-step-head strong { - color: #0f172a; -} -.status-step-effects { - display: flex; - flex-direction: column; - gap: 2px; - padding-left: 8px; - font-family: ui-monospace, monospace; - font-size: 12px; -} -.effect { - color: #334155; -} -.effect-consume { - color: #9a3412; -} -.effect-produce { - color: #047857; -} -.effect-call { - color: #475569; - font-style: italic; -} -.status-step-reason { - padding-left: 8px; -} +/* --- 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; } diff --git a/src/modules/flow/demo/useLmmLiveSnapshot.ts b/src/modules/flow/demo/useLmmLiveSnapshot.ts new file mode 100644 index 0000000000..a6f7812f11 --- /dev/null +++ b/src/modules/flow/demo/useLmmLiveSnapshot.ts @@ -0,0 +1,142 @@ +'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; +} + +// 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; +}; + +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) { + setTopology(result); + setInspectError(undefined); + } + } catch (e) { + if (!cancelled) { + setInspectError(e instanceof Error ? e.message : String(e)); + setTopology(null); + } + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [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/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx index 2e3d7591e1..0915e6a123 100644 --- a/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx +++ b/src/modules/flow/pages/flowPolicyDetailPage/flowPolicyDetailPageClient.tsx @@ -232,7 +232,7 @@ export const FlowPolicyDetailPageClient: React.FC< ) : ( <> - + {policy.pending != null ? `${formatFlowAmount(policy.pending.amount, policy.pending.token)} ${policy.pending.token} claimable` : 'Claim window open'} @@ -295,8 +295,9 @@ const dispatchDisabledReason = (policy: IFlowPolicy, nowMs: number): string => { const StatusBadge: React.FC<{ policy: IFlowPolicy }> = ({ policy }) => { const toneByStatus: Record = { ready: 'bg-primary-100 text-primary-800', - live: 'bg-success-100 text-success-800', - cooldown: 'bg-warning-100 text-warning-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', diff --git a/src/modules/flow/providers/flowDataProvider.tsx b/src/modules/flow/providers/flowDataProvider.tsx index d68fd06c41..8713f3d329 100644 --- a/src/modules/flow/providers/flowDataProvider.tsx +++ b/src/modules/flow/providers/flowDataProvider.tsx @@ -26,6 +26,10 @@ import { networkDefinitions } from '@/shared/constants/networkDefinitions'; // 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'; @@ -112,6 +116,19 @@ export interface IFlowDataContext { * `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); @@ -147,6 +164,13 @@ export const FlowDataProvider: React.FC = (props) => { // 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 @@ -464,6 +488,11 @@ export const FlowDataProvider: React.FC = (props) => { 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, @@ -476,6 +505,8 @@ export const FlowDataProvider: React.FC = (props) => { toasts, dismissToast, chainNowMs, + now, + liveSnapshot, }), [ resolvedDaoId, @@ -488,6 +519,8 @@ export const FlowDataProvider: React.FC = (props) => { toasts, dismissToast, chainNowMs, + now, + liveSnapshot, ], ); @@ -507,3 +540,11 @@ export const useFlowDataContext = (): IFlowDataContext => { } 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/utils/envioFlowMapper.ts b/src/modules/flow/utils/envioFlowMapper.ts index 15ce80be57..88a74a2565 100644 --- a/src/modules/flow/utils/envioFlowMapper.ts +++ b/src/modules/flow/utils/envioFlowMapper.ts @@ -1076,7 +1076,44 @@ const mapPolicy = (params: { proposalByTxHash, ), ); - const events = envioPolicy.events.map((e) => mapEvent(e, 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; @@ -1168,21 +1205,7 @@ const mapPolicy = (params: { ? `Recipients (${recipients.length})` : 'No recipients yet', dispatches, - events: - events.length > 0 - ? events - : [ - { - id: `${envioPolicy.id}-installed`, - kind: 'policyInstalled', - at: isoFromSeconds(envioPolicy.installedAt), - title: 'Policy installed', - description: `Installed at block ${envioPolicy.installBlockNumber}.`, - txHash: installTxHash, - proposalId: installAttribution?.proposalId, - proposalSlug: installAttribution?.slug, - }, - ], + events, schema: { source: restPolicy?.strategy.source?.type ?? '—', allowance: { From a4c35f44a47ba60381747357b966a207e8568366 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 27 May 2026 21:07:11 +0200 Subject: [PATCH 07/13] feat: Add Lido Money Machine demo configuration and enhance layout logic - Introduced new environment variables for the Lido Money Machine demo to support demo mode and external resources. - Refactored layout logic in the flow components to improve node ordering and propagation of strategy indices, ensuring a more intuitive visual representation of the flow graph. - Enhanced sorting mechanism for nodes to maintain a stable order while minimizing visual disruptions. These updates improve the functionality and clarity of the Lido Money Machine demo, particularly in the context of orchestrator strategies. --- config/.env.preview | 7 +++ .../components/lidoMoneyMachine/layout.ts | 62 ++++++++++++++----- 2 files changed, 55 insertions(+), 14 deletions(-) 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/src/modules/flow/components/lidoMoneyMachine/layout.ts b/src/modules/flow/components/lidoMoneyMachine/layout.ts index ca72e3fbe8..7d095bb8c2 100644 --- a/src/modules/flow/components/lidoMoneyMachine/layout.ts +++ b/src/modules/flow/components/lidoMoneyMachine/layout.ts @@ -55,32 +55,62 @@ export function layout( // // `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 strategies - // ordered left→right by on-chain index (`[0]`, `[1]`, `[2]`), which - // `toReactFlowGraph` emits as `composes`-edge labels. + // 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). // - // We rebuild the cross-axis coordinate per rank by sorting nodes that - // carry a `[N]` order index, preserving the rank's existing extent so - // the layout doesn't visually jump. + // 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 orderByTarget = new Map(); + const orderByNode = new Map(); for (const e of graph.edges) { - if (e.type !== 'composes' && !(e.label && /^\[\d+\]$/.test(e.label))) { - continue; - } const m = (e.label ?? '').match(/^\[(\d+)\]$/); if (m) { - orderByTarget.set(e.target, Number(m[1])); + 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 (orderByTarget.size > 0) { + if (orderByNode.size > 0) { const buckets = new Map< number, Array<{ id: string; cross: number; order: number }> >(); for (const n of graph.nodes) { - const order = orderByTarget.get(n.id); + const order = orderByNode.get(n.id); if (order == null) { continue; } @@ -98,7 +128,11 @@ export function layout( const crosses = list .map((entry) => entry.cross) .sort((a, b) => a - b); - list.sort((a, b) => a.order - b.order); + // 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) { From 4eda4cb2f09881f1f5b0c158717c1961d1ee909e Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 27 May 2026 22:30:54 +0200 Subject: [PATCH 08/13] feat: Enhance Lido Money Machine demo configuration and flow components - Added new environment variables for Hasura integration in the docker-compose setup to improve communication with the Hasura GraphQL endpoint. - Updated nginx configuration to strip conflicting CORS headers from responses, ensuring proper handling of CORS in the Lido Money Machine demo. - Refactored flow components to improve the handling of dispatches and enhance the user interface, including updates to the recipients table and dispatch dialogs. --- infra/lmm-demo/vm/docker-compose.yml | 15 ++++++- infra/lmm-demo/vm/nginx.lmm-demo.conf | 23 ++++++++++ .../flowRecipientsTable.tsx | 34 ++++++++------ .../lidoMoneyMachine/LmmCheatsMenu.tsx | 21 ++++++--- .../components/lidoMoneyMachine/actions.ts | 30 +++++++++++++ src/modules/flow/utils/envioFlowMapper.ts | 44 +++++++++++++++++-- 6 files changed, 144 insertions(+), 23 deletions(-) diff --git a/infra/lmm-demo/vm/docker-compose.yml b/infra/lmm-demo/vm/docker-compose.yml index f8db1f95a6..b42a8a9a23 100644 --- a/infra/lmm-demo/vm/docker-compose.yml +++ b/infra/lmm-demo/vm/docker-compose.yml @@ -92,10 +92,18 @@ services: # default here is the bootstrap value used before init-demo.sh has run # for the first time. ENVIO_DEMO_START_BLOCK: ${ENVIO_DEMO_START_BLOCK:-25179300} + # 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: /srv/lmm/.env.indexer + - path: ${MANIFEST_OUTPUT_DIR:-/srv/lmm}/.env.indexer required: false volumes: - ${HOST_INDEXER_PATH:-../../../../capital-flow-indexer}:/indexer @@ -117,6 +125,11 @@ services: # 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" diff --git a/infra/lmm-demo/vm/nginx.lmm-demo.conf b/infra/lmm-demo/vm/nginx.lmm-demo.conf index 2ab60879b2..fbd5959819 100644 --- a/infra/lmm-demo/vm/nginx.lmm-demo.conf +++ b/infra/lmm-demo/vm/nginx.lmm-demo.conf @@ -56,6 +56,17 @@ location = /rpc { 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; @@ -90,6 +101,18 @@ location = /graphql { # 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; diff --git a/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx b/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx index ae60c30880..ea8a25e282 100644 --- a/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx +++ b/src/modules/flow/components/flowRecipientsTable/flowRecipientsTable.tsx @@ -184,14 +184,16 @@ const buildDestinationsRows = ( const amountsByToken: Partial> = {}; let lastReceivedAt: string | undefined; for (const d of matching) { - if (d.amount > 0) { + // 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 (d.amountIn != null && d.tokenIn && d.amountIn > 0) { - amountsByToken[d.tokenIn] = - (amountsByToken[d.tokenIn] ?? 0) + d.amountIn; - } if ( lastReceivedAt == null || new Date(d.at).getTime() > new Date(lastReceivedAt).getTime() @@ -231,7 +233,7 @@ const useLmmDestinationSeeds = ( address: manifest.lmm.dao, name: 'LMM DAO (self · wstETH)', role: 'dao', - group: 'Wrap leg', + group: 'Wrap', legKinds: ['WRAP'], }); const lpRecipient = @@ -243,7 +245,7 @@ const useLmmDestinationSeeds = ( address: lpRecipient, name: 'Lido Agent (UniV2 LP)', role: 'linkedaccount', - group: 'UniV2 LP leg', + group: 'UniV2 LP', legKinds: ['UNIV2_LIQUIDITY'], }); const cowSwap = manifest.cowSwap?.settlement; @@ -252,7 +254,7 @@ const useLmmDestinationSeeds = ( address: cowSwap, name: 'CowSwap settlement', role: 'router', - group: 'Buyback leg', + group: 'Buyback', legKinds: ['GATED_COWSWAP', 'COWSWAP'], }); } @@ -351,9 +353,11 @@ export const FlowRecipientsTable: React.FC = (
    - + {!isDestinationsVariant && ( + + )} + {!isDestinationsVariant && ( + + )}
    Recipient + {isDestinationsVariant + ? 'Destination' + : 'Recipient'} + - Total received + {isDestinationsVariant + ? 'Cumulative routed' + : 'Total received'} - Last received + Last routed - # dispatches + # legs - Share + {isDestinationsVariant + ? 'Role' + : 'Share'} - {row.ratio ?? - (row.pct != null - ? `${row.pct}%` - : '—')} + {isDestinationsVariant + ? row.group + : (row.ratio ?? + (row.pct != null + ? `${row.pct}%` + : '—'))}
    Last routed - # legs - + # dispatches + {isDestinationsVariant ? 'Role' @@ -415,9 +419,11 @@ export const FlowRecipientsTable: React.FC = ( ) : '—'} - - {row.dispatchCount ?? 0} - + {row.dispatchCount ?? 0} + {isDestinationsVariant ? row.group diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx index 97fe1cfb5f..f4f76910f1 100644 --- a/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/LmmCheatsMenu.tsx @@ -20,7 +20,7 @@ import { refreshOracle, setEthPrice, setTargetEpoch, - settleCowSwap, + settleCowSwapAuto, topUpLdo, topUpStEth, warpAction, @@ -166,10 +166,21 @@ export const LmmCheatsMenu: React.FC = () => { key: 'settle-cowswap', label: 'Settle CoW order (mock)', description: - 'Simulate the settlement contract executing the pending order.', - run: runAction('settle-cowswap', () => - settleCowSwap(ctx, parseEther('1'), parseEther('3000')), - ), + '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.', + ); + } + }), }, ], ]; diff --git a/src/modules/flow/components/lidoMoneyMachine/actions.ts b/src/modules/flow/components/lidoMoneyMachine/actions.ts index dfb55b4ab4..5b0d4ea2dd 100644 --- a/src/modules/flow/components/lidoMoneyMachine/actions.ts +++ b/src/modules/flow/components/lidoMoneyMachine/actions.ts @@ -63,6 +63,10 @@ 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)', ]); @@ -350,6 +354,32 @@ export async function settleCowSwap( }); } +/** 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: { diff --git a/src/modules/flow/utils/envioFlowMapper.ts b/src/modules/flow/utils/envioFlowMapper.ts index 88a74a2565..625cf9d299 100644 --- a/src/modules/flow/utils/envioFlowMapper.ts +++ b/src/modules/flow/utils/envioFlowMapper.ts @@ -679,10 +679,48 @@ const mapExecutionToDispatch = ( // 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). - // Falling through to the generic `dominantOut ?? 0 USDC` fallback - // would print a misleading "0 USDC" row — surface a sentinel instead - // and let the History/Chart skip the amount block for these. 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), From 457d43df3cda7c710969b7dfa92266de63b2b021 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 1 Jun 2026 19:09:41 +0200 Subject: [PATCH 09/13] refactor: Remove Money Flow --- .../lidoMoneyMachine/LmmPolicyTopology.tsx | 3 - .../lidoMoneyMachine/TopologyView.tsx | 108 +-------- .../lidoMoneyMachine/buildMoneyFlowGraph.ts | 227 ------------------ 3 files changed, 6 insertions(+), 332 deletions(-) delete mode 100644 src/modules/flow/components/lidoMoneyMachine/buildMoneyFlowGraph.ts diff --git a/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx b/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx index 66fd3f2b4e..3793e55e7c 100644 --- a/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/LmmPolicyTopology.tsx @@ -12,7 +12,6 @@ import classNames from 'classnames'; import type { Address } from 'viem'; import { LMM_RPC_URL } from '@/modules/flow/demo/lmmDemoConfig'; -import { useLmmManifest } from '@/modules/flow/demo/useLmmManifest'; import { useFlowDataContext } from '@/modules/flow/providers/flowDataProvider'; import { StatusPanel } from './StatusPanel'; import { TopologyView } from './TopologyView'; @@ -34,7 +33,6 @@ interface ILmmPolicyTopologyProps { export const LmmPolicyTopology: React.FC = (props) => { const { lidoDaoAddress, selectedNodeAddress, className } = props; const { liveSnapshot } = useFlowDataContext(); - const { manifest } = useLmmManifest(); const topology = liveSnapshot?.topology ?? null; const statusSnapshot = liveSnapshot?.snapshot ?? undefined; const error = liveSnapshot?.error; @@ -83,7 +81,6 @@ export const LmmPolicyTopology: React.FC = (props) => { diff --git a/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx b/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx index 9954f117c2..c2e9111bb5 100644 --- a/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx +++ b/src/modules/flow/components/lidoMoneyMachine/TopologyView.tsx @@ -3,17 +3,14 @@ // Adapted: imports rewritten to use the in-tree @/shared/lidoPreview vendoring // of @aragon/lido-preview, with .ts/.tsx extensions stripped for moduleResolution=bundler. -import { Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; import { Background, Controls, type Edge, MiniMap, type Node, - Panel, ReactFlow, } from '@xyflow/react'; -import classNames from 'classnames'; import { useEffect, useMemo, useState } from 'react'; import '@xyflow/react/dist/style.css'; import type { Address } from 'viem'; @@ -22,15 +19,11 @@ import { type TopologyGraph, toReactFlowGraph, } from '@/shared/lidoPreview'; -import type { LmmManifest } from '../../demo/lmmDemoConfig'; -import { buildMoneyFlowGraph } from './buildMoneyFlowGraph'; import { formatAmount, shortAddress } from './format'; import { layout } from './layout'; import { NodeDetails } from './NodeDetails'; import type { StatusSnapshot } from './useStatus'; -export type TopologyMode = 'wiring' | 'moneyFlow'; - // --- Live-value helpers (read from `status` and format for a node label) --- function liveBudgetLineForStrategy( @@ -172,7 +165,6 @@ export function TopologyView({ status, lidoDaoAddress, initialSelectedNodeAddress, - manifest, }: { topology: TopologyGraph; status?: StatusSnapshot; @@ -186,12 +178,8 @@ export function TopologyView({ * NodeDetails sidebar. Used by the dashboard orchestrator chip click * to land the user directly on the strategy they tapped. */ initialSelectedNodeAddress?: string; - /** Optional LMM manifest — when present the Money Flow toggle and node - * labels can render friendly names for known addresses. */ - manifest?: LmmManifest; }) { const [selected, setSelected] = useState(null); - const [mode, setMode] = useState('wiring'); const wiring = useMemo(() => { const raw = toReactFlowGraph(topology); @@ -226,30 +214,7 @@ export function TopologyView({ }; }, [topology, status, lidoDaoAddress]); - const moneyFlow = useMemo(() => { - const raw = buildMoneyFlowGraph(status?.nextDispatch ?? null, manifest); - if (raw.nodes.length === 0) { - return { nodes: [], edges: [], byId: new Map() }; - } - const laidOut = layout(raw, { rankdir: 'LR', ranksep: 140 }); - const map = new Map(); - for (const n of laidOut.nodes) { - map.set(n.id, n as GraphNode); - } - return { - nodes: laidOut.nodes.map(toMoneyFlowReactFlowNode), - edges: laidOut.edges.map(toReactFlowEdge), - byId: map, - }; - }, [status?.nextDispatch, manifest]); - - const isMoneyFlow = mode === 'moneyFlow'; - const moneyFlowEmpty = isMoneyFlow && moneyFlow.nodes.length === 0; - const moneyFlowSkipReason = isMoneyFlow - ? status?.nextDispatchError - : undefined; - const view = isMoneyFlow ? moneyFlow : wiring; - const { nodes, edges, byId } = view; + 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 @@ -271,28 +236,12 @@ export function TopologyView({ setSelected(match ?? null); }, [initialSelectedNodeAddress, byId]); - const modeToggle = ( - -
    - v != null && setMode(v as TopologyMode)} - value={mode} - > - - - -
    -
    - ); - return (
    { const original = byId.get(node.id); @@ -303,28 +252,11 @@ export function TopologyView({ onPaneClick={() => setSelected(null)} proOptions={{ hideAttribution: true }} > - {modeToggle} - {!moneyFlowEmpty && } - {!moneyFlowEmpty && ( - - )} - {moneyFlowEmpty && ( - - - No flow available - - - {moneyFlowSkipReason ?? - 'simulate() did not return any executed legs — wait for the live snapshot to refresh.'} - - - )} + + - {selected && !isMoneyFlow && ( + {selected && ( setSelected(null)} @@ -335,34 +267,6 @@ export function TopologyView({ ); } -// --- Money-flow node renderer -------------------------------------------- - -function toMoneyFlowReactFlowNode(node: GraphNode): Node { - const label = (node.data as { label?: string }).label ?? node.type; - const reason = (node.data as { reason?: string }).reason; - const skipped = node.type === 'money-step-skipped'; - // Reuse the wiring graph's `.rf-*` palette so the toggle doesn't read as - // two unrelated graphs: DAO node = `.rf-dao`, address/contract nodes = - // `.rf-recipient`, skipped steps = `.rf-strategy-unknown` (dashed - // neutral). Skip the dedicated `.rf-money-*` classes from earlier - // revisions — they were never declared in `styles.css`. - const sharedClass = - node.type === 'money-dao' - ? 'rf-dao' - : skipped - ? 'rf-strategy-unknown' - : 'rf-recipient'; - return { - id: node.id, - type: 'default', - position: node.position, - data: { - label: reason ? `${label}\n${reason}` : label, - }, - className: classNames('rf-node', sharedClass), - }; -} - // --- Conversion from our typed GraphNode to React Flow's Node -------------- function toReactFlowNode(node: GraphNode, status?: StatusSnapshot): Node { diff --git a/src/modules/flow/components/lidoMoneyMachine/buildMoneyFlowGraph.ts b/src/modules/flow/components/lidoMoneyMachine/buildMoneyFlowGraph.ts deleted file mode 100644 index 41259336fe..0000000000 --- a/src/modules/flow/components/lidoMoneyMachine/buildMoneyFlowGraph.ts +++ /dev/null @@ -1,227 +0,0 @@ -// LMM_DEMO_HACK: money-flow-from-simulate. Build a ReactFlow-compatible graph -// describing what `dispatch()` would actually do at the current chain head, -// driven by the vendored `simulate()`'s `FlowGraph`. Mirrors the dispatch -// dialog's per-leg breakdown but laid out horizontally so the operator can -// see the value path at a glance. -// -// Production replacement: emit `ExecutionTransfer` rows with a -// `flowDirection: in|out` field (or a dedicated `MoneyFlowEdge` entity) -// from the indexer so this can be reconstructed from historical data -// without re-running `simulate()` in the browser. See -// docs/lido-mmd-status.md row `money-flow-from-simulate`. - -import type { Address } from 'viem'; -import type { FlowGraph, TokenInfo } from '@/shared/lidoPreview'; -import type { LmmManifest } from '../../demo/lmmDemoConfig'; -import type { ReactFlowGraph } from '@/shared/lidoPreview'; - -const DAO_NODE = 'money/dao'; - -const sameAddress = (a: string | undefined, b: string | undefined): boolean => - a != null && b != null && a.toLowerCase() === b.toLowerCase(); - -/** Friendly node label for a known address from the manifest. */ -const labelForAddress = ( - address: Address, - manifest: LmmManifest | undefined, -): string => { - if (manifest == null) { - return shortAddress(address); - } - if (sameAddress(address, manifest.lmm.dao)) { - return 'LMM DAO'; - } - if (sameAddress(address, manifest.lido?.agent)) { - return 'Lido Agent · LP recipient'; - } - if (sameAddress(address, manifest.cowSwap?.settlement)) { - return 'CowSwap settlement'; - } - if (sameAddress(address, manifest.lido?.wstETH)) { - return 'wstETH contract'; - } - if (sameAddress(address, manifest.lido?.stETH)) { - return 'stETH contract'; - } - if (sameAddress(address, manifest.lido?.ldo)) { - return 'LDO token'; - } - if (sameAddress(address, manifest.lido?.uniV2Router)) { - return 'UniV2 router'; - } - if (sameAddress(address, manifest.lmm.dispatcherPlugin)) { - return 'DispatcherPlugin'; - } - return shortAddress(address); -}; - -const shortAddress = (a: string): string => `${a.slice(0, 6)}…${a.slice(-4)}`; - -const formatTokenAmount = (amount: bigint, token: TokenInfo): string => { - const decimals = token.decimals ?? 18; - if (decimals <= 0) { - return amount.toString(); - } - const base = BigInt(10) ** BigInt(decimals); - const whole = amount / base; - const frac = amount % base; - if (frac === 0n) { - return whole.toString(); - } - const fracStr = frac.toString().padStart(decimals, '0').slice(0, 4); - const trimmed = fracStr.replace(/0+$/, ''); - return trimmed.length > 0 ? `${whole}.${trimmed}` : whole.toString(); -}; - -const edgeLabel = (amount: bigint, token: TokenInfo): string => { - const sym = token.symbol ?? shortAddress(token.address); - return `${formatTokenAmount(amount, token)} ${sym}`; -}; - -const stepLabel = (kind: string): string => { - switch (kind) { - case 'strategy.dispatch.lido.wrap': - return 'Wrap'; - case 'strategy.dispatch.lido.univ2-liquidity': - return 'Add LP'; - case 'strategy.dispatch.lido.gated-cowswap': - return 'CowSwap'; - case 'strategy.dispatch.transfer': - return 'Transfer'; - case 'strategy.dispatch.burn': - return 'Burn'; - case 'strategy.dispatch.epoch-transfer': - return 'Epoch transfer'; - default: - return kind; - } -}; - -/** - * Build a money-flow `ReactFlowGraph` from a simulated FlowGraph. Returns - * a structurally-empty graph when the simulator failed or every step is a - * no-op — callers should render a "No flow available" hint in that case. - */ -export const buildMoneyFlowGraph = ( - flow: FlowGraph | null, - manifest: LmmManifest | undefined, -): ReactFlowGraph => { - if (flow == null) { - return { nodes: [], edges: [] }; - } - - const nodes = new Map(); - const edges: ReactFlowGraph['edges'] = []; - const nodeIdForAddress = new Map(); - - const ensureAddressNode = ( - address: Address, - type = 'money-account', - ): string => { - const key = address.toLowerCase(); - const existing = nodeIdForAddress.get(key); - if (existing != null) { - return existing; - } - const id = `money/addr/${key}`; - nodes.set(id, { - id, - type, - data: { - address, - label: labelForAddress(address, manifest), - }, - position: { x: 0, y: 0 }, - }); - nodeIdForAddress.set(key, id); - return id; - }; - - // Anchor DAO node first so dagre puts it leftmost. - nodes.set(DAO_NODE, { - id: DAO_NODE, - type: 'money-dao', - data: { - address: flow.initialState.dao, - label: labelForAddress(flow.initialState.dao, manifest), - }, - position: { x: 0, y: 0 }, - }); - nodeIdForAddress.set(flow.initialState.dao.toLowerCase(), DAO_NODE); - - let edgeCounter = 0; - const pushEdge = ( - source: string, - target: string, - label: string, - kind: string, - ): void => { - edges.push({ - id: `money/edge/${edgeCounter++}`, - source, - target, - label, - type: kind, - }); - }; - - for (const step of flow.steps) { - if (step.status !== 'executed') { - // Surface non-executed legs as a single "skipped" edge from DAO - // to a virtual step node so the operator sees why no value - // flowed through that path. - const stepNodeId = `money/step/${step.index}`; - nodes.set(stepNodeId, { - id: stepNodeId, - type: 'money-step-skipped', - data: { - label: `${stepLabel(step.strategyRef.kind)} · skipped`, - reason: step.reason, - }, - position: { x: 0, y: 0 }, - }); - pushEdge(DAO_NODE, stepNodeId, step.reason ?? 'skipped', 'skipped'); - continue; - } - - // 1) Transfers — explicit ERC-20 movements from DAO → recipient. - for (const t of step.transfers) { - const targetId = sameAddress(t.to, flow.initialState.dao) - ? DAO_NODE - : ensureAddressNode(t.to); - pushEdge( - DAO_NODE, - targetId, - edgeLabel(t.amount, t.token), - 'transfer', - ); - } - - // 2) External calls — DAO → contract (consumes) and contract → DAO - // (produces). Surface the call's verb (`wrap`, `swap`) as a - // short label on each edge so the operator can tell apart a - // Wrap call from a UniV2 swap. - for (const call of step.externalCalls) { - const verb = call.description.split('(')[0] || 'call'; - const contractId = ensureAddressNode(call.to); - for (const c of call.consumes) { - pushEdge( - DAO_NODE, - contractId, - `${verb} · ${edgeLabel(c.amount, c.token)}`, - 'call-consumes', - ); - } - for (const p of call.produces) { - pushEdge( - contractId, - DAO_NODE, - `${verb} · +${edgeLabel(p.amount, p.token)}`, - 'call-produces', - ); - } - } - } - - return { nodes: Array.from(nodes.values()), edges }; -}; From a3f86cf9f7f4142a52b44bdd8fd4f7b768710d77 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 3 Jun 2026 22:30:37 +0200 Subject: [PATCH 10/13] feat: Add demo script and flow workbench components for Lido Money Machine - Introduced a comprehensive demo script in `DEMO-SCRIPT.md` to guide presenters through the Lido Money Machine flow, detailing setup and interactive controls. - Created a new `FlowWorkbenchPage` component to visualize the flow, enhancing user interaction with the demo. - Updated the `README.md` to reference the new demo script for improved presentation guidance. - Enhanced flow module types and dynamics to support the new workbench functionality, ensuring accurate representation of flow states and actions. --- .agents/shared/metrics/hits.jsonl | 3 + infra/lmm-demo/DEMO-SCRIPT.md | 117 +++ infra/lmm-demo/README.md | 5 + .../[addressOrEns]/flow/workbench/page.tsx | 3 + .../flow/canvas/buildFlowGraph.test.ts | 219 +++++ src/modules/flow/canvas/buildFlowGraph.ts | 368 +++++++ src/modules/flow/canvas/flowDescriptor.ts | 148 +++ src/modules/flow/canvas/flowDynamics.ts | 304 ++++++ src/modules/flow/canvas/flowGraphTypes.ts | 309 ++++++ src/modules/flow/canvas/layoutFlowGraph.ts | 150 +++ src/modules/flow/canvas/primitiveRegistry.ts | 122 +++ .../flow/canvas/toIndexedDynamics.test.ts | 260 +++++ src/modules/flow/canvas/toIndexedDynamics.ts | 205 ++++ .../flow/components/flowLayout/flowLayout.tsx | 23 +- .../components/flowLayout/flowLayoutShell.tsx | 56 ++ .../flow/components/flowSubNav/flowSubNav.tsx | 9 + .../flow/components/flowTopbar/flowTopbar.tsx | 82 +- .../flowWorkbench/buildWorkbenchModel.test.ts | 360 +++++++ .../flowWorkbench/buildWorkbenchModel.ts | 363 +++++++ .../components/flowWorkbench/flowCanvas.tsx | 927 ++++++++++++++++++ .../components/flowWorkbench/flowFocus.tsx | 262 +++++ .../flowWorkbench/flowInfoPanels.tsx | 86 ++ .../flow/components/flowWorkbench/index.ts | 2 + .../flow/components/flowWorkbench/mmIcon.tsx | 116 +++ .../components/flowWorkbench/mmPrimitives.tsx | 201 ++++ .../flow/components/flowWorkbench/tone.ts | 58 ++ .../components/flowWorkbench/workbench.css | 83 ++ .../flowWorkbench/workbenchDemoActions.tsx | 216 ++++ .../flowWorkbench/workbenchHeader.tsx | 217 ++++ .../flowWorkbench/workbenchModel.ts | 115 +++ .../flowWorkbench/workbenchPanels.tsx | 720 ++++++++++++++ src/modules/flow/demo/useLmmActionContext.ts | 50 + src/modules/flow/demo/useLmmLiveSnapshot.ts | 56 +- src/modules/flow/index.ts | 1 + .../flowWorkbenchPage/flowWorkbenchPage.tsx | 20 + .../flowWorkbenchPageClient.tsx | 282 ++++++ .../flow/pages/flowWorkbenchPage/index.ts | 2 + src/modules/flow/types.ts | 16 + src/modules/flow/utils/envioFlowMapper.ts | 257 ++++- src/modules/flow/utils/flowAddressBook.ts | 26 +- .../api/flowIndexer/flowIndexerTypes.ts | 80 ++ src/shared/api/flowIndexer/index.ts | 5 + .../queries/useFlowIndexerDaoData.ts | 46 + src/shared/lidoPreview/simulate/predictors.ts | 73 +- 44 files changed, 6990 insertions(+), 33 deletions(-) create mode 100644 infra/lmm-demo/DEMO-SCRIPT.md create mode 100644 src/app/dao/[network]/[addressOrEns]/flow/workbench/page.tsx create mode 100644 src/modules/flow/canvas/buildFlowGraph.test.ts create mode 100644 src/modules/flow/canvas/buildFlowGraph.ts create mode 100644 src/modules/flow/canvas/flowDescriptor.ts create mode 100644 src/modules/flow/canvas/flowDynamics.ts create mode 100644 src/modules/flow/canvas/flowGraphTypes.ts create mode 100644 src/modules/flow/canvas/layoutFlowGraph.ts create mode 100644 src/modules/flow/canvas/primitiveRegistry.ts create mode 100644 src/modules/flow/canvas/toIndexedDynamics.test.ts create mode 100644 src/modules/flow/canvas/toIndexedDynamics.ts create mode 100644 src/modules/flow/components/flowLayout/flowLayoutShell.tsx create mode 100644 src/modules/flow/components/flowWorkbench/buildWorkbenchModel.test.ts create mode 100644 src/modules/flow/components/flowWorkbench/buildWorkbenchModel.ts create mode 100644 src/modules/flow/components/flowWorkbench/flowCanvas.tsx create mode 100644 src/modules/flow/components/flowWorkbench/flowFocus.tsx create mode 100644 src/modules/flow/components/flowWorkbench/flowInfoPanels.tsx create mode 100644 src/modules/flow/components/flowWorkbench/index.ts create mode 100644 src/modules/flow/components/flowWorkbench/mmIcon.tsx create mode 100644 src/modules/flow/components/flowWorkbench/mmPrimitives.tsx create mode 100644 src/modules/flow/components/flowWorkbench/tone.ts create mode 100644 src/modules/flow/components/flowWorkbench/workbench.css create mode 100644 src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx create mode 100644 src/modules/flow/components/flowWorkbench/workbenchHeader.tsx create mode 100644 src/modules/flow/components/flowWorkbench/workbenchModel.ts create mode 100644 src/modules/flow/components/flowWorkbench/workbenchPanels.tsx create mode 100644 src/modules/flow/demo/useLmmActionContext.ts create mode 100644 src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPage.tsx create mode 100644 src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPageClient.tsx create mode 100644 src/modules/flow/pages/flowWorkbenchPage/index.ts 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/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 index 1d2e24e330..8d994301c2 100644 --- a/infra/lmm-demo/README.md +++ b/infra/lmm-demo/README.md @@ -209,6 +209,11 @@ 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 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/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..b934bea6d5 --- /dev/null +++ b/src/modules/flow/canvas/buildFlowGraph.ts @@ -0,0 +1,368 @@ +/** + * 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'; + +const STRATEGY_W = 240; +const SOURCE_W = 252; +const RECIPIENT_W = 200; + +const STRATEGY_HEADER_H = 60; +const STRATEGY_BADGE_H = 28; +const STRATEGY_INPUT_H = 58; +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; + +const strategyHeight = (inputCount: number, hasBadge: boolean): number => + STRATEGY_HEADER_H + + (hasBadge ? STRATEGY_BADGE_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, + x: 0, + y: 0, + w: STRATEGY_W, + h: strategyHeight(inputs.length, badge != null), + }); + } + + /* ---- 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. + 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); + } + 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, + flowing: flowingOut && isMeaningful(outflow.amount), + blocked, + }); + 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..a8beddba97 --- /dev/null +++ b/src/modules/flow/canvas/flowDescriptor.ts @@ -0,0 +1,148 @@ +/** + * 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, + 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 }; +} + +/** 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(toStep); + + 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..2c3786d245 --- /dev/null +++ b/src/modules/flow/canvas/flowDynamics.ts @@ -0,0 +1,304 @@ +/** + * 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); + +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(), + ); + return budget + ? { + token: tokenSymbol(budget.token), + reading: toNumber(budget.amount, budget.token.decimals), + } + : {}; + } + if (input.role === 'gate') { + const gate = snapshot.gate; + if (!gate) { + return {}; + } + // The gate fails for TWO distinct reasons: the price is below the + // floor, OR the oracle reading is too old (`block.timestamp > + // updatedAt + maxStaleness`). Distinguish them so a stale-but-fine + // price isn't mislabelled "below floor": if the live price is at/above + // the threshold yet the gate still fails, it's staleness. + const belowFloor = + gate.price != null && gate.price < gate.threshold; + const reason = gate.passes + ? 'price above floor' + : belowFloor + ? 'price below floor' + : 'oracle price stale — refresh it'; + return { + status: gate.passes ? 'open' : 'closed', + detail: reason, + }; + } + // 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..8ed42574a2 --- /dev/null +++ b/src/modules/flow/canvas/flowGraphTypes.ts @@ -0,0 +1,309 @@ +/** + * 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[]; + /** 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; + 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; +} + +export interface IFlowStepDescriptor { + index: number; + address: string; + kind: FlowEmbeddedStrategyKind; + label: string; + subtitle?: string; + paused: boolean; + inputs: IFlowSubInputDescriptor[]; +} + +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`. + * ------------------------------------------------------------------------- */ + +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; +} + +/** 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..19c27582f4 --- /dev/null +++ b/src/modules/flow/canvas/primitiveRegistry.ts @@ -0,0 +1,122 @@ +/** + * 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', +}; + +/** 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/flowLayout/flowLayout.tsx b/src/modules/flow/components/flowLayout/flowLayout.tsx index d7dbd3d45c..dd198155f4 100644 --- a/src/modules/flow/components/flowLayout/flowLayout.tsx +++ b/src/modules/flow/components/flowLayout/flowLayout.tsx @@ -1,8 +1,6 @@ import type { ReactNode } from 'react'; import { FlowDataProvider } from '../../providers/flowDataProvider'; -import { FlowSubNav } from '../flowSubNav/flowSubNav'; -import { FlowToastStack } from '../flowToastStack/flowToastStack'; -import { FlowTopbar } from '../flowTopbar/flowTopbar'; +import { FlowLayoutShell } from './flowLayoutShell'; export interface IFlowLayoutProps { daoId: string; @@ -20,18 +18,13 @@ export const FlowLayout: React.FC = (props) => { daoId={daoId} network={network} > -
    - - -
    - {children} -
    - -
    + + {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/flowSubNav/flowSubNav.tsx b/src/modules/flow/components/flowSubNav/flowSubNav.tsx index c6de642b7a..f13a5cd2ab 100644 --- a/src/modules/flow/components/flowSubNav/flowSubNav.tsx +++ b/src/modules/flow/components/flowSubNav/flowSubNav.tsx @@ -17,6 +17,9 @@ interface IFlowTab { 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', @@ -46,6 +49,12 @@ export const FlowSubNav: React.FC = (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 (
    = ({ + 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; @@ -47,7 +121,7 @@ export const FlowTopbar: React.FC = (props) => { className, )} > -
    +
    {/* "Aragon Flow" wordmark: AragonLogo renders the brand * "Aragon" wordmark in `text-primary-400` (currentColor @@ -78,7 +152,11 @@ export const FlowTopbar: React.FC = (props) => {
    -
    +
    +
    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..38f91bd802 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/flowCanvas.tsx @@ -0,0 +1,927 @@ +'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 { + getRecipientDisplay, + getSourceDisplay, + getStrategyDisplay, + getSubInputDisplay, +} from '../../canvas/primitiveRegistry'; +import { MmIcon } from './mmIcon'; +import { Amount, Pill } from './mmPrimitives'; +import { MM_STATES, toneAccentBorder, 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)'; + } + 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; + } + const k = clampK(Math.min(cw / stageW, ch / stageH, 1)); + return { k, x: (cw - stageW * k) / 2, y: (ch - 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'; + return ( +
    + + + +
    +
    + {sub.label} + {closed && closed} + {open && open} +
    + {sub.note && ( +
    + {sub.note} +
    + )} + {sub.detail && ( +
    + {sub.detail} +
    + )} +
    +
    + {sub.reading != null && ( + + )} + {sub.role === 'epoch' && sub.epoch != null && ( + + #{sub.epoch.toLocaleString('en-US')} + {sub.epochLength ? ` · ${sub.epochLength}` : ''} + + )} +
    +
    + ); +}; + +/* ----------------------------------------------------------- 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 ? ( + + pending + + ) : ( + + {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'; + const display = isSource ? getSourceDisplay() : getRecipientDisplay(); + return ( + + ); +}; + +/* --------------------------------------------------------------- EdgeLabel */ + +const EdgeLabel: React.FC<{ + edge: IFlowGraphEdge; + mid: IPoint; + replaying: boolean; +}> = ({ edge, mid, replaying }) => ( +
    + + {edge.perEpoch && ( + /epoch + )} + {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(); + const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; + 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..560979cc42 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/flowFocus.tsx @@ -0,0 +1,262 @@ +'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 { + 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-xl backdrop-blur-md'; + +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. +
    +
    + )} +
    + + {/* top-left: flow selector + live badge, with the flow description + stacked beneath it */} +
    +
    + + {model.isLive && } +
    + {description && ( + + )} +
    + + {/* top-center: next-run + dispatch controls */} +
    +
    + {model.nextRun && ( + + )} + {model.nextRun && ( + + )} + setSimOpen(true)} + /> + +
    + {runForBanner && ( +
    + +
    + )} +
    + + {/* right column: this-flow stats + inspector */} +
    + {model.stats && ( + +
    + + Cumulative + + + {model.stats.activeSinceDays}d + +
    + + {model.stats.tokenFlows.length > 0 && ( +
    +
    + Token throughput +
    + +
    + )} +
    + )} + {selectedNode && ( + setSelected(null)} + recipient={recipient} + /> + )} +
    + + {/* bottom-left: collapsible dispatch history */} +
    + {historyOpen ? ( +
    + setHistoryOpen(false)} + onSelect={(run) => + run === activeRun + ? onClearReplay() + : onReplay(run) + } + /> +
    + ) : ( + + )} +
    + + {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..486c159c0e --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/flowInfoPanels.tsx @@ -0,0 +1,86 @@ +'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 { MmIcon } from './mmIcon'; +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..cbf91d37a4 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/mmPrimitives.tsx @@ -0,0 +1,201 @@ +'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) { + const val = /LP/.test(token ?? '') ? 'LP' : (token ?? '?'); + return ( + + + {val} + + + pending + + + ); + } + + 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..9b06947c78 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/tone.ts @@ -0,0 +1,58 @@ +/** + * 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' }, +}; + +/** Left accent bar (node module) — directional border colour. */ +export const toneAccentBorder: Record = { + neutral: 'border-l-neutral-400', + info: 'border-l-info-500', + primary: 'border-l-primary-500', + critical: 'border-l-critical-500', + success: 'border-l-success-600', + warning: 'border-l-warning-500', +}; + +/** 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..ab147a8156 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx @@ -0,0 +1,216 @@ +'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; + +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: 'skip-epoch', + label: 'Skip an epoch', + description: 'Bump the stream target epoch by one.', + icon: 'clock', + run: (ctx) => setTargetEpoch(ctx, 1), + }, + { + 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); + }, + }, + ], + }, +]; + +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} + > +
    + 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..096c73f9cc --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx @@ -0,0 +1,217 @@ +'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 { fmtAmount, Switch } from './mmPrimitives'; +import type { + INextRun, + ITokenAmount, + IWorkbenchModel, + IWorkbenchStats, +} from './workbenchModel'; + +const joinTokens = (items: ITokenAmount[]): string => + items.length === 0 + ? '—' + : items + .map((t) => `${fmtAmount(t.amount) ?? '·'} ${t.token}`) + .join(' · '); + +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, +}) => { + 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: 'Total moved', value: joinTokens(stats.totalMoved) }, + ]; + if (stats.buybacks.length > 0) { + items.push({ label: 'Buybacks', value: joinTokens(stats.buybacks) }); + } + 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')} + + + + )} +
    +
    +); + +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; + return ( +
    +
    + + + Auto-dispatch + + +
    + {/* One entry point: simulate first (shows the plan + net outcome), + then confirm the dispatch from inside that modal. Disabled (with + the reason on hover) when nothing would actually 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..701c90bec6 --- /dev/null +++ b/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx @@ -0,0 +1,720 @@ +'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 { 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) => ( + + } + /> + ))} + + )} +
    + ); +}; + +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 */ + +const RUN_STATUS_TONE: Record = { + ok: 'success', + partial: 'warning', + failed: 'critical', +}; + +const legTone = (leg: { failed?: boolean; skipped?: boolean }): MmTone => { + if (leg.failed) { + return 'critical'; + } + if (leg.skipped) { + return 'warning'; + } + return 'success'; +}; + +export interface IHistoryStripProps { + history: IHistoryRun[]; + activeRun: string | null; + onSelect: (run: string) => void; + onClear: () => void; + /** When provided, renders a collapse chevron in the header (Focus layout). */ + onCollapse?: () => void; +} + +export const HistoryStrip: React.FC = (props) => { + const { history, activeRun, onSelect, onClear, onCollapse } = props; + return ( +
    +
    +
    + + Dispatch history + + {history.length} + +
    +
    + {activeRun != null && ( + + )} + {onCollapse && ( + + )} +
    +
    + {history.length === 0 ? ( +
    + No dispatches yet for this flow. +
    + ) : ( +
    + {history.map((h) => ( + + ))} +
    + )} +
    + ); +}; + +/* -------------------------------------------------------------- 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 && ( + + )} +
    +
    +
    + + {s.kind} + + + {s.willFire ? 'will fire' : 'skipped'} + +
    +
    + {s.ins.map((io) => ( + + ))} + + {s.outs.map((io) => ( + + ))} +
    + {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 ? ( + + pending + + ) : ( + + {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/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/useLmmLiveSnapshot.ts b/src/modules/flow/demo/useLmmLiveSnapshot.ts index a6f7812f11..4e2dd80812 100644 --- a/src/modules/flow/demo/useLmmLiveSnapshot.ts +++ b/src/modules/flow/demo/useLmmLiveSnapshot.ts @@ -54,6 +54,18 @@ export interface IUseLmmLiveSnapshotResult { 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 => { @@ -67,6 +79,17 @@ const getClient = (): PublicClient => { 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, @@ -101,10 +124,21 @@ export const useLmmLiveSnapshot = (): IUseLmmLiveSnapshotResult => { const run = async (): Promise => { try { const result = await inspect(getClient(), dispatcher); - if (!cancelled) { - setTopology(result); - setInspectError(undefined); + 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)); @@ -113,8 +147,24 @@ export const useLmmLiveSnapshot = (): IUseLmmLiveSnapshotResult => { } }; 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]); diff --git a/src/modules/flow/index.ts b/src/modules/flow/index.ts index f946627671..834f441802 100644 --- a/src/modules/flow/index.ts +++ b/src/modules/flow/index.ts @@ -5,6 +5,7 @@ export * from './pages/flowActivityPage'; export * from './pages/flowOverviewPage'; export * from './pages/flowPolicyDetailPage'; export * from './pages/flowRecipientsPage'; +export * from './pages/flowWorkbenchPage'; export { FlowDataProvider, useFlowDataContext, 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..8e57d47cf8 --- /dev/null +++ b/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPageClient.tsx @@ -0,0 +1,282 @@ +'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 { 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]); + + 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, + }); + }, [data, orchestrator, recipient]); + + // 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/types.ts b/src/modules/flow/types.ts index 8bdf0f595c..fdb78f7ec1 100644 --- a/src/modules/flow/types.ts +++ b/src/modules/flow/types.ts @@ -1,3 +1,5 @@ +import type { IFlowIndexedStep } from './canvas/flowGraphTypes'; + export type FlowPolicyStatus = | 'ready' | 'live' @@ -292,6 +294,13 @@ export interface IFlowOrchestratorRun { 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 { @@ -393,6 +402,13 @@ export interface IFlowOrchestrator { */ 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; } diff --git a/src/modules/flow/utils/envioFlowMapper.ts b/src/modules/flow/utils/envioFlowMapper.ts index 625cf9d299..f8ff185982 100644 --- a/src/modules/flow/utils/envioFlowMapper.ts +++ b/src/modules/flow/utils/envioFlowMapper.ts @@ -26,8 +26,13 @@ import { } from '@/shared/api/daoService'; import type { EnvioEmbeddedStrategyKind, + EnvioFlowEdgeRole, + EnvioFlowStepStatus, + EnvioProvenance, EnvioStrategyType, IEnvioExecutionTransfer, + IEnvioFlowEdge, + IEnvioFlowStep, IEnvioPolicy, IEnvioPolicyEvent, IEnvioPolicyExecution, @@ -35,6 +40,13 @@ import type { IEnvioStrategy, IFlowDaoDataResponse, } from '@/shared/api/flowIndexer'; +import type { + FlowFidelity, + FlowIndexedEdgeRole, + FlowIndexedStatus, + IFlowIndexedEdge, + IFlowIndexedStep, +} from '../canvas/flowGraphTypes'; import type { FlowEmbeddedStrategyKind, FlowEventKind, @@ -1396,6 +1408,211 @@ const mapEmbeddedStrategies = ( 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) // --------------------------------------------------------------------------- @@ -1405,9 +1622,16 @@ const buildOrchestrator = (params: { 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 } = - params; + const { + envioPolicy, + restPolicy, + policiesByAddress, + proposalByTxHash, + flowSteps, + } = params; const subRouterAddresses = (restPolicy?.strategy.subRouters ?? []).map( (a) => a.toLowerCase(), @@ -1416,11 +1640,18 @@ const buildOrchestrator = (params: { (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 chain) { + for (const child of indexed ? [] : chain) { if (!child) { continue; } @@ -1465,9 +1696,12 @@ const buildOrchestrator = (params: { } } - const runs = Array.from(runsByTxHash.values()).sort( - (a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(), - ); + 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); @@ -1513,6 +1747,7 @@ const buildOrchestrator = (params: { chain, embeddedStrategies: mapEmbeddedStrategies(envioPolicy.strategies), runs, + latestIndexedSteps, lastRunAt: runs[0]?.at, totalRuns: runs.length, }; @@ -1667,6 +1902,12 @@ export const buildFlowDataFromEnvio = ( 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(); @@ -1709,6 +1950,10 @@ export const buildFlowDataFromEnvio = ( ), policiesByAddress, proposalByTxHash, + flowSteps: + flowStepsByDispatcher.get( + envioPolicy.pluginAddress.toLowerCase(), + ) ?? [], }), ); const mirror = policiesByAddress.get( diff --git a/src/modules/flow/utils/flowAddressBook.ts b/src/modules/flow/utils/flowAddressBook.ts index 4dc4442e3e..f8c30eda22 100644 --- a/src/modules/flow/utils/flowAddressBook.ts +++ b/src/modules/flow/utils/flowAddressBook.ts @@ -66,6 +66,23 @@ const BURN_ADDRESSES: Record = { '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; @@ -101,14 +118,19 @@ export const buildFlowAddressBook = ( ): IFlowAddressBook => { const entries = new Map(); - // Burn addresses take the lowest precedence so a DAO that happens to use a - // burn address for some reason still wins. + // 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 diff --git a/src/shared/api/flowIndexer/flowIndexerTypes.ts b/src/shared/api/flowIndexer/flowIndexerTypes.ts index 8f435805c7..6bc0d2d06f 100644 --- a/src/shared/api/flowIndexer/flowIndexerTypes.ts +++ b/src/shared/api/flowIndexer/flowIndexerTypes.ts @@ -226,7 +226,87 @@ export interface IEnvioRecipientAggregate { 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 index 7696ae41db..92e7ae7995 100644 --- a/src/shared/api/flowIndexer/index.ts +++ b/src/shared/api/flowIndexer/index.ts @@ -9,15 +9,20 @@ export type { EnvioBudgetKind, EnvioEmbeddedStrategyKind, EnvioExecutionKind, + EnvioFlowEdgeRole, + EnvioFlowStepStatus, EnvioGateKind, EnvioPolicyEventKind, EnvioPolicyStatus, + EnvioProvenance, EnvioStrategyType, EnvioTransferDecodedFrom, IEnvioBudget, IEnvioDao, IEnvioEpochProvider, IEnvioExecutionTransfer, + IEnvioFlowEdge, + IEnvioFlowStep, IEnvioGate, IEnvioPolicy, IEnvioPolicyEvent, diff --git a/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts b/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts index 4edd0e37b0..96694067bf 100644 --- a/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts +++ b/src/shared/api/flowIndexer/queries/useFlowIndexerDaoData.ts @@ -175,6 +175,52 @@ const FLOW_DAO_DATA_QUERY = /* GraphQL */ ` 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 + } + } + } } `; diff --git a/src/shared/lidoPreview/simulate/predictors.ts b/src/shared/lidoPreview/simulate/predictors.ts index d2e158e306..1506c304e7 100644 --- a/src/shared/lidoPreview/simulate/predictors.ts +++ b/src/shared/lidoPreview/simulate/predictors.ts @@ -292,6 +292,7 @@ const univ2FactoryAbi = parseAbi([ 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([ @@ -434,6 +435,30 @@ registerPredictor({ 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: { @@ -441,7 +466,19 @@ registerPredictor({ token: budgetA.token, provenance: budgetA.provenance, }, - transfers: [], + 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, @@ -460,12 +497,25 @@ registerPredictor({ provenance: 'estimated-via-oracle', }, ], - // LP minted to lpRecipient (NOT the DAO) — no `produces` on the vault. - produces: [], + // 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: [ - `LP tokens minted to ${node.lpRecipient}, not the DAO`, + 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 @@ -568,7 +618,20 @@ registerPredictor({ minBuy.amount } ${node.targetToken.symbol ?? '?'} (off-chain fill)`, consumes: [], - produces: [], + // 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: [ From 4da39c975db8521019855c916bdcf41334491756 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 4 Jun 2026 11:59:54 +0200 Subject: [PATCH 11/13] feat: Enhance Lido Money Machine demo with strategy parameters and memory limits - Updated `docker-compose.yml` to set memory limits for anvil, postgres, and node services, ensuring better resource management during demo execution. - Modified `init-demo.sh` to recreate the envio container instead of restarting it, ensuring new environment variables are applied correctly. - Enhanced flow components to support display of strategy parameters (e.g., slippage, target token) in the flow workbench, improving user visibility of configuration settings. - Introduced a new module for strategy parameters, allowing dynamic rendering of configuration details in the flow canvas and inspector. --- infra/lmm-demo/vm/docker-compose.yml | 41 +++++++++-- infra/lmm-demo/vm/init-demo.sh | 14 ++-- src/modules/flow/canvas/buildFlowGraph.ts | 18 ++++- src/modules/flow/canvas/flowDescriptor.ts | 15 +++- src/modules/flow/canvas/flowDynamics.ts | 27 ++++++-- src/modules/flow/canvas/flowGraphTypes.ts | 14 ++++ .../components/flowWorkbench/flowCanvas.tsx | 7 ++ .../flowWorkbench/workbenchDemoActions.tsx | 34 +++++++-- .../flowWorkbench/workbenchPanels.tsx | 23 +++++++ src/modules/flow/demo/strategyParams.ts | 69 +++++++++++++++++++ .../flowWorkbenchPageClient.tsx | 13 +++- 11 files changed, 251 insertions(+), 24 deletions(-) create mode 100644 src/modules/flow/demo/strategyParams.ts diff --git a/infra/lmm-demo/vm/docker-compose.yml b/infra/lmm-demo/vm/docker-compose.yml index b42a8a9a23..27699a5129 100644 --- a/infra/lmm-demo/vm/docker-compose.yml +++ b/infra/lmm-demo/vm/docker-compose.yml @@ -29,6 +29,19 @@ services: # --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}" @@ -39,6 +52,10 @@ services: - "--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 @@ -49,6 +66,8 @@ services: postgres: image: postgres:17-alpine restart: unless-stopped + mem_limit: 2g + mem_reservation: 256m environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} @@ -70,6 +89,8 @@ services: # for build instructions. image: node:22-bookworm-slim restart: unless-stopped + mem_limit: 4g + mem_reservation: 512m working_dir: /indexer depends_on: postgres: @@ -86,12 +107,18 @@ services: # 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 - # Block at which Envio starts indexing chain-id 1. Init-demo.sh writes - # the actual fork-block (manifest.blockNumber - 5) into /srv/lmm/.env - # after every `just demo-up`; we read it from there via env_file. The - # default here is the bootstrap value used before init-demo.sh has run - # for the first time. - ENVIO_DEMO_START_BLOCK: ${ENVIO_DEMO_START_BLOCK:-25179300} + # 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 @@ -114,6 +141,8 @@ services: hasura: image: hasura/graphql-engine:v2.42.0 restart: unless-stopped + mem_limit: 1g + mem_reservation: 256m depends_on: postgres: condition: service_healthy diff --git a/infra/lmm-demo/vm/init-demo.sh b/infra/lmm-demo/vm/init-demo.sh index 3b91406a44..8c4a5be02a 100755 --- a/infra/lmm-demo/vm/init-demo.sh +++ b/infra/lmm-demo/vm/init-demo.sh @@ -119,12 +119,18 @@ EOF mv -f "${MANIFEST_OUTPUT_DIR}/.env.indexer.tmp" "${MANIFEST_OUTPUT_DIR}/.env.indexer" # Bounce envio so it picks up the new start_block. Anvil + postgres + hasura -# stay up; only the indexer container is restarted. envio's config-hash diff +# stay up; only the indexer container is recreated. envio's config-hash diff # would have wiped the DB anyway on the next run because config.yaml's start # block changed — but doing it explicitly keeps the log story clean. -if docker compose -f "$COMPOSE_FILE" ps --status running --services 2>/dev/null | grep -q '^envio$'; then - log "restarting envio container to apply new start_block" - docker compose -f "$COMPOSE_FILE" restart envio +# +# IMPORTANT: use `up -d --force-recreate`, NOT `restart`. `docker compose +# restart` reuses the existing container and does NOT re-read `env_file`, so the +# ENVIO_DEMO_START_BLOCK we just wrote into ${MANIFEST_OUTPUT_DIR}/.env.indexer +# would be ignored and envio would keep indexing from its boot-time start_block. +# Recreating forces the new env_file value to take effect. +if docker compose -f "$COMPOSE_FILE" ps --services 2>/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)" diff --git a/src/modules/flow/canvas/buildFlowGraph.ts b/src/modules/flow/canvas/buildFlowGraph.ts index b934bea6d5..b6c6c04096 100644 --- a/src/modules/flow/canvas/buildFlowGraph.ts +++ b/src/modules/flow/canvas/buildFlowGraph.ts @@ -73,9 +73,18 @@ export const MEANINGFUL_AMOUNT_EPS = 1e-9; const isMeaningful = (amount: number | null | undefined): boolean => amount == null || Math.abs(amount) > MEANINGFUL_AMOUNT_EPS; -const strategyHeight = (inputCount: number, hasBadge: boolean): number => +// 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); @@ -160,10 +169,15 @@ export const buildFlowGraph = (params: IBuildFlowGraphParams): IFlowGraph => { state, badge, inputs, + params: step.params, x: 0, y: 0, w: STRATEGY_W, - h: strategyHeight(inputs.length, badge != null), + h: strategyHeight( + inputs.length, + badge != null, + (step.params?.length ?? 0) > 0, + ), }); } diff --git a/src/modules/flow/canvas/flowDescriptor.ts b/src/modules/flow/canvas/flowDescriptor.ts index a8beddba97..1090a3efb6 100644 --- a/src/modules/flow/canvas/flowDescriptor.ts +++ b/src/modules/flow/canvas/flowDescriptor.ts @@ -14,6 +14,7 @@ import type { IFlowEmbeddedStrategy, IFlowOrchestrator } from '../types'; import type { IFlowMachineDescriptor, IFlowStepDescriptor, + IFlowStrategyParam, IFlowSubInputDescriptor, } from './flowGraphTypes'; import { @@ -29,6 +30,10 @@ export interface IToDescriptorOptions { 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 @@ -137,7 +142,15 @@ export const toFlowMachineDescriptor = ( if (embedded == null || embedded.length === 0) { return null; } - const steps = [...embedded].sort((a, b) => a.index - b.index).map(toStep); + 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, diff --git a/src/modules/flow/canvas/flowDynamics.ts b/src/modules/flow/canvas/flowDynamics.ts index 2c3786d245..ab141a3ea2 100644 --- a/src/modules/flow/canvas/flowDynamics.ts +++ b/src/modules/flow/canvas/flowDynamics.ts @@ -200,12 +200,27 @@ const readingsForStep = ( b.strategyAddress.toLowerCase() === descriptorStep.address.toLowerCase(), ); - return budget - ? { - token: tokenSymbol(budget.token), - reading: toNumber(budget.amount, budget.token.decimals), - } - : {}; + 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; diff --git a/src/modules/flow/canvas/flowGraphTypes.ts b/src/modules/flow/canvas/flowGraphTypes.ts index 8ed42574a2..8f0349fa3d 100644 --- a/src/modules/flow/canvas/flowGraphTypes.ts +++ b/src/modules/flow/canvas/flowGraphTypes.ts @@ -128,6 +128,9 @@ export interface IFlowGraphNode { 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). */ @@ -187,6 +190,14 @@ export interface IFlowSubInputDescriptor { 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; @@ -195,6 +206,9 @@ export interface IFlowStepDescriptor { 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 { diff --git a/src/modules/flow/components/flowWorkbench/flowCanvas.tsx b/src/modules/flow/components/flowWorkbench/flowCanvas.tsx index 38f91bd802..407f4a7aa0 100644 --- a/src/modules/flow/components/flowWorkbench/flowCanvas.tsx +++ b/src/modules/flow/components/flowWorkbench/flowCanvas.tsx @@ -474,6 +474,13 @@ const StrategyModule: React.FC<{ {state.label} + {node.params && node.params.length > 0 && ( + + {node.params + .map((p) => `${p.label} ${p.value}`) + .join(' · ')} + + )} {node.inputs.length > 0 && ( diff --git a/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx b/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx index ab147a8156..de54401aaf 100644 --- a/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx +++ b/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx @@ -35,6 +35,15 @@ import { MmIcon } from './mmIcon'; 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; @@ -107,11 +116,15 @@ const ACTION_GROUPS: { heading: string; actions: IDemoAction[] }[] = [ heading: '4 · Advance time', actions: [ { - key: 'skip-epoch', - label: 'Skip an epoch', - description: 'Bump the stream target epoch by one.', + 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: (ctx) => setTargetEpoch(ctx, 1), + run: async (ctx) => { + await warpAction(ctx, EPOCH_SECONDS); + await refreshOracle(ctx); + }, }, { key: 'warp-1d', @@ -135,6 +148,19 @@ const ACTION_GROUPS: { heading: string; actions: IDemoAction[] }[] = [ }, ], }, + { + 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 = () => { diff --git a/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx b/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx index 701c90bec6..06bf807692 100644 --- a/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx +++ b/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx @@ -192,6 +192,29 @@ const StrategyInspector: React.FC<{ ))} )} + + {node.params && node.params.length > 0 && ( + <> +
    + Parameters +
    +
    + {node.params.map((p) => ( +
    + + {p.label} + + + {p.value} + +
    + ))} +
    + + )}
    ); }; diff --git a/src/modules/flow/demo/strategyParams.ts b/src/modules/flow/demo/strategyParams.ts new file mode 100644 index 0000000000..c2158b82c5 --- /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 || 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/pages/flowWorkbenchPage/flowWorkbenchPageClient.tsx b/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPageClient.tsx index 8e57d47cf8..210b13d65c 100644 --- a/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPageClient.tsx +++ b/src/modules/flow/pages/flowWorkbenchPage/flowWorkbenchPageClient.tsx @@ -22,6 +22,7 @@ import { } 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'; @@ -107,6 +108,15 @@ export const FlowWorkbenchPageClient: React.FC< 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; @@ -116,8 +126,9 @@ export const FlowWorkbenchPageClient: React.FC< recipient: recipient ? { address: recipient.address ?? '', label: recipient.label } : undefined, + paramsByAddress: strategyParams, }); - }, [data, orchestrator, recipient]); + }, [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 From cda86af2bacca7bfed71b78fdaeb075e1f657bb9 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 4 Jun 2026 16:03:43 +0200 Subject: [PATCH 12/13] feat: Implement async output settling and enhance flow graph rendering - Introduced `outputsSettleAsync` function to determine if strategy outputs settle out-of-band, improving flow graph accuracy. - Updated `buildFlowGraph` to handle settle-phase outputs distinctly, ensuring they render correctly and do not animate with immediate dispatch flows. - Enhanced `flowGraphTypes` to include a `trigger` property for edges, allowing differentiation between 'dispatch' and 'settle' outputs. - Modified flow dynamics to provide clearer visual cues for settle-phase flows, improving user understanding of flow states. - Refactored flow components to accommodate new rendering logic for outputs, enhancing overall user experience in the flow workbench. --- src/modules/flow/canvas/buildFlowGraph.ts | 18 ++- src/modules/flow/canvas/flowDynamics.ts | 33 ++++-- src/modules/flow/canvas/flowGraphTypes.ts | 10 ++ src/modules/flow/canvas/primitiveRegistry.ts | 15 +++ .../components/flowWorkbench/flowCanvas.tsx | 110 +++++++++++------- .../flowWorkbench/workbenchDemoActions.tsx | 63 +++++----- .../flowWorkbench/workbenchHeader.tsx | 17 +-- src/modules/flow/demo/strategyParams.ts | 2 +- src/modules/flow/utils/envioFlowMapper.ts | 2 +- 9 files changed, 170 insertions(+), 100 deletions(-) diff --git a/src/modules/flow/canvas/buildFlowGraph.ts b/src/modules/flow/canvas/buildFlowGraph.ts index b6c6c04096..a57911e84e 100644 --- a/src/modules/flow/canvas/buildFlowGraph.ts +++ b/src/modules/flow/canvas/buildFlowGraph.ts @@ -43,6 +43,7 @@ import type { IFlowSubInput, } from './flowGraphTypes'; import { layoutFlowGraph } from './layoutFlowGraph'; +import { outputsSettleAsync } from './primitiveRegistry'; const STRATEGY_W = 240; const SOURCE_W = 252; @@ -50,7 +51,9 @@ const RECIPIENT_W = 200; const STRATEGY_HEADER_H = 60; const STRATEGY_BADGE_H = 28; -const STRATEGY_INPUT_H = 58; +// 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; @@ -239,6 +242,11 @@ export const buildFlowGraph = (params: IBuildFlowGraphParams): IFlowGraph => { // 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)) { @@ -249,6 +257,9 @@ export const buildFlowGraph = (params: IBuildFlowGraphParams): IFlowGraph => { 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, @@ -258,8 +269,11 @@ export const buildFlowGraph = (params: IBuildFlowGraphParams): IFlowGraph => { amount: outflow.amount, fidelity: outflow.fidelity, perEpoch: outflow.perEpoch, - flowing: flowingOut && isMeaningful(outflow.amount), + // Settle-phase outputs don't animate as the dispatch "now". + flowing: + flowingOut && isMeaningful(outflow.amount) && !settles, blocked, + trigger, }); stepOutputs.push({ token: outflow.token, diff --git a/src/modules/flow/canvas/flowDynamics.ts b/src/modules/flow/canvas/flowDynamics.ts index ab141a3ea2..e2961441a8 100644 --- a/src/modules/flow/canvas/flowDynamics.ts +++ b/src/modules/flow/canvas/flowDynamics.ts @@ -38,6 +38,13 @@ const tokenSymbol = (token: TokenInfo): string => 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'; @@ -227,21 +234,25 @@ const readingsForStep = ( if (!gate) { return {}; } - // The gate fails for TWO distinct reasons: the price is below the - // floor, OR the oracle reading is too old (`block.timestamp > - // updatedAt + maxStaleness`). Distinguish them so a stale-but-fine - // price isn't mislabelled "below floor": if the live price is at/above - // the threshold yet the gate still fails, it's staleness. + // 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 reason = gate.passes - ? 'price above floor' - : belowFloor - ? 'price below floor' - : 'oracle price stale — refresh it'; + const detail = + gate.passes || belowFloor + ? `now ${now} · floor ${floor}` + : `oracle stale · floor ${floor}`; return { status: gate.passes ? 'open' : 'closed', - detail: reason, + detail, }; } // epoch diff --git a/src/modules/flow/canvas/flowGraphTypes.ts b/src/modules/flow/canvas/flowGraphTypes.ts index 8f0349fa3d..64e038df64 100644 --- a/src/modules/flow/canvas/flowGraphTypes.ts +++ b/src/modules/flow/canvas/flowGraphTypes.ts @@ -164,6 +164,9 @@ export interface IFlowGraphEdge { 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; } @@ -226,6 +229,10 @@ export interface IFlowMachineDescriptor { * 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). */ @@ -237,6 +244,9 @@ export interface IFlowEdgeFlow { 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 diff --git a/src/modules/flow/canvas/primitiveRegistry.ts b/src/modules/flow/canvas/primitiveRegistry.ts index 19c27582f4..504caaa3e2 100644 --- a/src/modules/flow/canvas/primitiveRegistry.ts +++ b/src/modules/flow/canvas/primitiveRegistry.ts @@ -86,6 +86,21 @@ const FALLBACK: IPrimitiveDisplay = { 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; diff --git a/src/modules/flow/components/flowWorkbench/flowCanvas.tsx b/src/modules/flow/components/flowWorkbench/flowCanvas.tsx index 407f4a7aa0..6850d158f2 100644 --- a/src/modules/flow/components/flowWorkbench/flowCanvas.tsx +++ b/src/modules/flow/components/flowWorkbench/flowCanvas.tsx @@ -231,6 +231,12 @@ const edgeColor = (edge: IFlowGraphEdge, replaying: boolean): string => { 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)'; } @@ -346,59 +352,70 @@ const SubChip: React.FC<{ sub: IFlowSubInput; muted: boolean }> = ({ 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 ( + // Stacked, not crammed onto one line: row 1 icon + title (+ status), + // row 2 the static description (note), row 3 the live values (budget + // amount, epoch #, gate now-vs-floor). Rows 2/3 are indented to line up + // under the title.
    - - - -
    -
    +
    + + + + {sub.label} - {closed && closed} - {open && open} -
    - {sub.note && ( -
    - {sub.note} -
    - )} - {sub.detail && ( -
    - {sub.detail} -
    - )} -
    -
    - {sub.reading != null && ( - - )} - {sub.role === 'epoch' && sub.epoch != null && ( - - #{sub.epoch.toLocaleString('en-US')} - {sub.epochLength ? ` · ${sub.epochLength}` : ''} - - )} + + {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} + + )} +
    + )}
    ); }; @@ -648,6 +665,7 @@ const EdgeLabel: React.FC<{ edge.fidelity === 'estimated' && 'border-info-200', edge.fidelity === 'opaque' && 'border-primary-200 border-dashed', edge.fidelity === 'real' && 'border-neutral-200', + edge.trigger === 'settle' && 'border-warning-300 border-dashed', edge.blocked && 'border-critical-300', replaying && 'opacity-45', )} @@ -661,6 +679,14 @@ const EdgeLabel: React.FC<{ {edge.perEpoch && ( /epoch )} + {edge.trigger === 'settle' && ( + + + + + on settle + + )} {edge.blocked && ( diff --git a/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx b/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx index de54401aaf..8f237da331 100644 --- a/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx +++ b/src/modules/flow/components/flowWorkbench/workbenchDemoActions.tsx @@ -191,7 +191,6 @@ export const WorkbenchDemoActions: React.FC = () => { return ( { onOpenChange={setOpen} open={open} > -
    - Demo controls · Anvil fork -
    - {ACTION_GROUPS.map((group) => ( -
    -
    - {group.heading} -
    - {group.actions.map((action) => ( - - - - - - - - {action.label} - {busyKey === action.key && ' …'} + {/* 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.description} + + + {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 index 096c73f9cc..bdf13c965d 100644 --- a/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx +++ b/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx @@ -10,21 +10,13 @@ import { Button, Dropdown, IconType } from '@aragon/gov-ui-kit'; import { MmIcon } from './mmIcon'; -import { fmtAmount, Switch } from './mmPrimitives'; +import { Switch } from './mmPrimitives'; import type { INextRun, - ITokenAmount, IWorkbenchModel, IWorkbenchStats, } from './workbenchModel'; -const joinTokens = (items: ITokenAmount[]): string => - items.length === 0 - ? '—' - : items - .map((t) => `${fmtAmount(t.amount) ?? '·'} ${t.token}`) - .join(' · '); - interface IFlowSelectorProps { dao: string; flows: IWorkbenchModel['flows']; @@ -99,6 +91,9 @@ export const FlowSelector: React.FC = (props) => { export const CumulativeStats: React.FC<{ stats: IWorkbenchStats }> = ({ stats, }) => { + // KPIs only. Per-token "moved / back" lives in the TOKEN THROUGHPUT table + // below (out = moved, back = buybacks), which scales to any token set — so + // we no longer cram multi-token blobs ("Total moved" / "Buybacks") here. const items: { label: string; value: string; accent?: boolean }[] = [ { label: 'Dispatches', @@ -109,11 +104,7 @@ export const CumulativeStats: React.FC<{ stats: IWorkbenchStats }> = ({ value: `${Math.round(stats.successRate * 100)}%`, accent: true, }, - { label: 'Total moved', value: joinTokens(stats.totalMoved) }, ]; - if (stats.buybacks.length > 0) { - items.push({ label: 'Buybacks', value: joinTokens(stats.buybacks) }); - } return (
    {items.map((it) => ( diff --git a/src/modules/flow/demo/strategyParams.ts b/src/modules/flow/demo/strategyParams.ts index c2158b82c5..3ad7ee89a8 100644 --- a/src/modules/flow/demo/strategyParams.ts +++ b/src/modules/flow/demo/strategyParams.ts @@ -56,7 +56,7 @@ export const strategyParamsByAddress = ( topology: TopologyGraph | null, ): Map => { const map = new Map(); - if (!topology || topology.root.kind !== 'plugin.dispatch') { + if (topology?.root.kind !== 'plugin.dispatch') { return map; } for (const node of topology.root.strategies) { diff --git a/src/modules/flow/utils/envioFlowMapper.ts b/src/modules/flow/utils/envioFlowMapper.ts index f8ff185982..a7b26493a9 100644 --- a/src/modules/flow/utils/envioFlowMapper.ts +++ b/src/modules/flow/utils/envioFlowMapper.ts @@ -861,7 +861,7 @@ const deriveStreamCooldownFromStrategies = ( for (const s of strategies) { const budget = s.budget; const epochProvider = s.epochProvider; - if (!budget || budget.kind !== 'STREAM_UNTIL') { + if (budget?.kind !== 'STREAM_UNTIL') { continue; } if (!epochProvider?.epochLength) { From dbdd0e50a365a6eb0c9e4f63ecd60fe9fcd7132f Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 15 Jun 2026 20:50:04 +0200 Subject: [PATCH 13/13] fix: Update flow component styles and layout for improved usability - Increased the width of the strategy component for better visibility. - Refactored flow canvas layout to accommodate inset margins, enhancing the overall user experience. - Adjusted text styles for better readability and consistency across components. - Improved the handling of flow description and dispatch controls for a cleaner interface. - Updated demo button styles for better alignment and user interaction. --- src/modules/flow/canvas/buildFlowGraph.ts | 2 +- .../components/flowWorkbench/flowCanvas.tsx | 124 +++---- .../components/flowWorkbench/flowFocus.tsx | 99 +++--- .../flowWorkbench/flowInfoPanels.tsx | 16 +- .../components/flowWorkbench/mmPrimitives.tsx | 13 +- .../flow/components/flowWorkbench/tone.ts | 10 - .../flowWorkbench/workbenchDemoActions.tsx | 3 +- .../flowWorkbench/workbenchHeader.tsx | 79 ++--- .../flowWorkbench/workbenchPanels.tsx | 302 +++++++++--------- 9 files changed, 331 insertions(+), 317 deletions(-) diff --git a/src/modules/flow/canvas/buildFlowGraph.ts b/src/modules/flow/canvas/buildFlowGraph.ts index a57911e84e..a3869b7686 100644 --- a/src/modules/flow/canvas/buildFlowGraph.ts +++ b/src/modules/flow/canvas/buildFlowGraph.ts @@ -45,7 +45,7 @@ import type { import { layoutFlowGraph } from './layoutFlowGraph'; import { outputsSettleAsync } from './primitiveRegistry'; -const STRATEGY_W = 240; +const STRATEGY_W = 288; const SOURCE_W = 252; const RECIPIENT_W = 200; diff --git a/src/modules/flow/components/flowWorkbench/flowCanvas.tsx b/src/modules/flow/components/flowWorkbench/flowCanvas.tsx index 6850d158f2..7a8afbaee9 100644 --- a/src/modules/flow/components/flowWorkbench/flowCanvas.tsx +++ b/src/modules/flow/components/flowWorkbench/flowCanvas.tsx @@ -23,14 +23,12 @@ import type { IFlowSubInput, } from '../../canvas/flowGraphTypes'; import { - getRecipientDisplay, - getSourceDisplay, getStrategyDisplay, getSubInputDisplay, } from '../../canvas/primitiveRegistry'; import { MmIcon } from './mmIcon'; import { Amount, Pill } from './mmPrimitives'; -import { MM_STATES, toneAccentBorder, toneChip, tonePulseVar } from './tone'; +import { MM_STATES, toneChip, tonePulseVar } from './tone'; const REDUCED = typeof window !== 'undefined' && @@ -288,8 +286,22 @@ const usePanZoom = ( if (!(cw && ch)) { return null; } - const k = clampK(Math.min(cw / stageW, ch / stageH, 1)); - return { k, x: (cw - stageW * k) / 2, y: (ch - stageH * k) / 2 }; + // 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(() => { @@ -355,10 +367,9 @@ const SubChip: React.FC<{ sub: IFlowSubInput; muted: boolean }> = ({ const showEpoch = sub.role === 'epoch' && sub.epoch != null; const hasValues = sub.reading != null || showEpoch || sub.detail != null; return ( - // Stacked, not crammed onto one line: row 1 icon + title (+ status), - // row 2 the static description (note), row 3 the live values (budget - // amount, epoch #, gate now-vs-floor). Rows 2/3 are indented to line up - // under the title. + // 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} + {closed && Closed} + {open && Open}
    {sub.note && ( -
    +
    {sub.note}
    )} {hasValues && ( -
    +
    {sub.reading != null && ( )} @@ -403,16 +414,16 @@ const SubChip: React.FC<{ sub: IFlowSubInput; muted: boolean }> = ({ )} {sub.detail && ( - {sub.detail} - +
    )}
    )} @@ -445,8 +456,7 @@ const StrategyModule: React.FC<{ > diff --git a/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx b/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx index bdf13c965d..5b725dfcea 100644 --- a/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx +++ b/src/modules/flow/components/flowWorkbench/workbenchHeader.tsx @@ -34,7 +34,7 @@ export const FlowSelector: React.FC = (props) => { constrainContentWidth={false} customTrigger={
    - {/* One entry point: simulate first (shows the plan + net outcome), - then confirm the dispatch from inside that modal. Disabled (with - the reason on hover) when nothing would actually move. A disabled - - -
    + +
    ); }; diff --git a/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx b/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx index 06bf807692..53ba841c14 100644 --- a/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx +++ b/src/modules/flow/components/flowWorkbench/workbenchPanels.tsx @@ -19,7 +19,12 @@ import { import { MmIcon } from './mmIcon'; import { Amount, Pill } from './mmPrimitives'; import { MM_STATES, type MmTone, toneChip } from './tone'; -import type { IHistoryRun, INextRun, RunStatus } from './workbenchModel'; +import type { + IHistoryLeg, + IHistoryRun, + INextRun, + RunStatus, +} from './workbenchModel'; const truncateAddress = (address?: string): string => address && address.length > 12 @@ -118,7 +123,7 @@ const StrategyInspector: React.FC<{
    {node.inputs.length > 0 && ( -
    +
    Inputs
    )} @@ -164,7 +169,7 @@ const StrategyInspector: React.FC<{ {node.outputs && node.outputs.length > 0 && ( <> -
    +
    Output
    {node.outputs.map((out) => ( @@ -195,7 +200,7 @@ const StrategyInspector: React.FC<{ {node.params && node.params.length > 0 && ( <> -
    +
    Parameters
    @@ -246,7 +251,7 @@ const EndpointInspector: React.FC<{ node: IFlowGraphNode }> = ({ node }) => { {isSource && node.balances && node.balances.length > 0 && ( <> -
    +
    Balances
    {node.balances.map((b) => ( @@ -262,7 +267,7 @@ const EndpointInspector: React.FC<{ node: IFlowGraphNode }> = ({ node }) => { {!isSource && node.address && ( <> -
    +
    Address
    = (props) => { return (
    - + Inspector {node && ( @@ -320,7 +325,7 @@ const STATE_SWATCH: [label: string, className: string][] = [ export const Legend: React.FC = () => (
    -
    +
    Node state
    @@ -341,7 +346,7 @@ export const Legend: React.FC = () => (
    -
    +
    Data fidelity
    @@ -364,69 +369,57 @@ export const Legend: React.FC = () => ( /* ------------------------------------------------------------- HistoryStrip */ -const RUN_STATUS_TONE: Record = { - ok: 'success', - partial: 'warning', - failed: 'critical', +/** 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', }; -const legTone = (leg: { failed?: boolean; skipped?: boolean }): MmTone => { - if (leg.failed) { - return 'critical'; +/** 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'; } - if (leg.skipped) { - return 'warning'; - } - return 'success'; + 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; - onClear: () => void; /** When provided, renders a collapse chevron in the header (Focus layout). */ onCollapse?: () => void; } export const HistoryStrip: React.FC = (props) => { - const { history, activeRun, onSelect, onClear, onCollapse } = props; + const { history, activeRun, onSelect, onCollapse } = props; return ( -
    -
    +
    +
    - Dispatch history {history.length}
    -
    - {activeRun != null && ( - - )} - {onCollapse && ( - - )} -
    + {/* Exiting replay lives on the ReplayBanner ("Back to live") — no + duplicate control here. */} + {onCollapse && ( + + )}
    {history.length === 0 ? (
    @@ -434,59 +427,73 @@ export const HistoryStrip: React.FC = (props) => {
    ) : (
    - {history.map((h) => ( - - ))} + key={h.run} + > + + + + {h.label} + + + + {legSummary(h.legs)} + + + {h.at} + + + +
    + ); + })}
    )}
    @@ -611,52 +618,55 @@ export const SimulateModal: React.FC = (props) => { )}
    + {/* No "will fire" badge for standard execution — + the filled dot already signals it. Skips show + a single inline alert below instead. */}
    {s.kind} - - {s.willFire ? 'will fire' : 'skipped'} -
    -
    - {s.ins.map((io) => ( - - ))} - - {s.outs.map((io) => ( - + {s.ins.map((io) => ( + + ))} + - ))} -
    - {s.skipReason && ( -
    - - {s.skipReason} + {s.outs.map((io) => ( + + ))} +
    + ) : ( +
    + + + Skipped + + {s.skipReason && ( + + {s.skipReason} + + )}
    )}
    @@ -666,7 +676,7 @@ export const SimulateModal: React.FC = (props) => { {nextRun.net.length > 0 && (
    -
    +
    Net to the DAO vault
    @@ -683,8 +693,8 @@ export const SimulateModal: React.FC = (props) => { {entry.token} {pending ? ( - - pending + + on fill ) : (